Prove every ext-state write by reading the key back, so no guard is constant-true

SetProjExtState's return describes the whole extname. The persist and the instrument's publish now re-read their key; both refusals can finally fire.
This commit is contained in:
2026-08-02 13:14:49 -04:00
parent 0afb885987
commit 25390d5253
14 changed files with 231 additions and 112 deletions
+2 -2
View File
@@ -77,10 +77,10 @@ This directory owns two cross-artifact contracts specifically:
## Modules
- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory.
- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`, and its peer `extStateWriteLanded` — the ONLY verdict on whether a `SetProjExtState` write took, because that API's return is the size of the WHOLE extname's state and cannot speak for one key. Three shells bind it: the bake landing's answer/clear write-back, `saveToActiveProject`'s `banks` write, and the instrument bridge's two prefix-guarded writes. Its `nullopt` means ABSENT specifically, so every caller must route an Overflow read to its own "could not check" answer rather than folding it in. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory.
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. It owns BOTH ends' reading of that key, since the key's contents are the only evidence either side gets: `classifyBakeAnswer` (instrument side — six kinds, of which `Unanswered`, the request still sitting there untouched, separates "nothing wrote an outcome over our key" from a refusal — it does NOT identify a landing that never ran, since a skipped key and a rejected answer-write look identical from here) and `classifyBakeScan` + `kMaxRequestAgeSeconds` (extension side — the per-key Land / RefuseWrongProject / ClearStale / IgnoreUnreadable / IgnoreNotARequest verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable). `BakeScanTally` + `describeBakeScan` + `describeBakeKey` are that same reading counted and spoken — the rationale lives at the types. **The report's absence is NOT evidence the landing never ran**, and no sentence either artifact prints may say it is: `answered` is pass-wide and counts a QUEUED write, so a pass can answer some other key while skipping ours, or have our own answer's `SetProjExtState` rejected. The summary is therefore silent only when the pass answered somebody, left no key unanswered, and every answer was READ BACK from its own key; `describeBakeKey` prints one line per enumerated key regardless, which is the only thing that names WHICH key — the counts cannot. `bakeWriteLanded` is that read-back's verdict and the reason it has to exist: `SetProjExtState` returns the size of the whole extname's state, which `banks` alone keeps non-zero in every case a bake can reach, so nothing but re-reading the key can say whether THAT key took the value — and its `nullopt` means ABSENT specifically, so a caller must route a failed or overflowed read to `BakeWriteProof::Unknown` rather than folding it in as an absence. `bakeLandingAfterPersist` is the ONE route to a `Banked` landing: every `Land` verdict is assigned `Unpersisted` and passed through it, so no shell path — a dedup hit least of all, since its target may be an entry the same pass just added — can claim the word without the pass's persist having reported success.
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. It owns BOTH ends' reading of that key, since the key's contents are the only evidence either side gets: `classifyBakeAnswer` (instrument side — six kinds, of which `Unanswered`, the request still sitting there untouched, separates "nothing wrote an outcome over our key" from a refusal — it does NOT identify a landing that never ran, since a skipped key and a rejected answer-write look identical from here) and `classifyBakeScan` + `kMaxRequestAgeSeconds` (extension side — the per-key Land / RefuseWrongProject / ClearStale / IgnoreUnreadable / IgnoreNotARequest verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable). `BakeScanTally` + `describeBakeScan` + `describeBakeKey` are that same reading counted and spoken — the rationale lives at the types. **The report's absence is NOT evidence the landing never ran**, and no sentence either artifact prints may say it is: `answered` is pass-wide and counts a QUEUED write, so a pass can answer some other key while skipping ours, or have our own answer's `SetProjExtState` rejected. The summary is therefore silent only when the pass answered somebody, left no key unanswered, and every answer was READ BACK from its own key; `describeBakeKey` prints one line per enumerated key regardless, which is the only thing that names WHICH key — the counts cannot. The read-back verdict behind `BakeWriteProof` is `ext_state_read.h`'s `extStateWriteLanded`, above. `bakeLandingAfterPersist` is the ONE route to a `Banked` landing: every `Land` verdict is assigned `Unpersisted` and passed through it, so no shell path — a dedup hit least of all, since its target may be an entry the same pass just added — can claim the word without the pass's persist having CONFIRMED its bank write. What `Banked` claims is exactly that `persisted` input; the enum comment is that claim's one home.
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns `classifyReaperSurface`, the prefix classifier mapping a `GetThingFromPoint` (info token, track-present) pair onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which now claims the WHOLE track panel rather than just its FX sub-elements. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
+14 -17
View File
@@ -246,19 +246,18 @@ namespace {
std::string describeLanding(BakeLanding landing) {
switch (landing) {
case BakeLanding::Banked:
// The flag is ALL that was observed, so the sentence claims no more than it:
// saveToActiveProject issues its SetProjExtState calls without reading their
// returns. The trailing clause is SetProjExtState's own documented semantics
// (reaper_plugin_functions.h — a value is restored "the next time this specific
// project is loaded"), not a claim about the file's current contents.
return "landed into the bank, and this pass's persist reported success -- the "
".rpp itself carries it once the project is saved";
// Bounded by what `persisted` actually observed (see BakeLanding): the stored
// bank state read back as this pass wrote it. The trailing clause is an
// instruction, not a claim about the .rpp's current contents.
return "landed into the bank, and the project's stored bank state read back as "
"exactly what this pass wrote -- save the project to keep it";
case BakeLanding::Unpersisted:
// Says nothing about what the project holds: the persist can fail before
// writing anything OR throw part-way through, and a dedup hit's target may
// have been in the project since long before this pass.
// writing anything, throw part-way through, or write and fail to read back —
// and a dedup hit's target may have been in the project since long before this
// pass.
return "landed into the bank IN MEMORY ONLY -- this pass's persist did not "
"report success, so nothing here can say the project will carry it";
"confirm its bank write, so nothing here can say the project carries it";
case BakeLanding::Partial:
return "the landing failed after it had begun writing -- it may have left a "
"file in the bank folder and an entry in memory";
@@ -306,9 +305,12 @@ std::string describeBakeKey(const std::string& key, const BakeKeyOutcome& outcom
case BakeWriteProof::Confirmed:
return aged + ", so it was cleared unanswered.\n";
case BakeWriteProof::Rejected:
// Not "could not be read back": Rejected also covers a clear that was
// never issued, where no read happened at all. What holds either way is
// the consequence.
return aged +
", but the clear could NOT be read back -- the key still holds a "
"value and the next pass will see it again.\n";
", but the clear did NOT take -- the key still holds a value and "
"the next pass will see it again.\n";
case BakeWriteProof::Unknown:
break;
}
@@ -332,9 +334,4 @@ std::string describeBakeKey(const std::string& key, const BakeKeyOutcome& outcom
return s + describeProof(outcome.proof);
}
bool bakeWriteLanded(const std::string& written, const std::optional<std::string>& readBack) {
if (written.empty()) return !readBack || readBack->empty();
return readBack && *readBack == written;
}
} // namespace reasampler::wire
+14 -27
View File
@@ -181,7 +181,6 @@ struct BakeScanTally {
// as no answer at all
int writeUnproven = 0; // of `answered`, the ones whose write-and-check itself failed:
// NOT known to have landed and NOT known to have failed
int landed = 0; // of `answered`, the ones that reached the bank
};
// The console summary for one pass. Empty ONLY when the pass answered somebody AND left no
@@ -192,18 +191,25 @@ std::string describeBakeScan(const BakeScanTally& tally);
// How far a `Land` verdict actually got. `Banked` is reachable ONLY through
// bakeLandingAfterPersist below — a landing may not claim the word on its own, because
// nothing a landing can see says whether the pass's persist ran.
// nothing a landing can see says whether the pass's persist ran. What `Banked` claims is
// exactly its `persisted` input and nothing more; this is that claim's one home.
enum class BakeLanding {
Refused, // nothing was written and the book is untouched
Partial, // it threw AFTER it had begun writing — a file and/or an entry may exist
Unpersisted, // it reached the in-memory book; the pass's persist did not report success
Banked, // in the book, and the pass's persist reported success
Unpersisted, // it reached the in-memory book; the pass's persist did not confirm
Banked, // in the book, and the pass's persist confirmed its bank write
};
// The ONE route to `Banked`: every Land verdict's landing is assigned `Unpersisted` and
// then passed through here. The special case this replaces — a dedup hit answered `Banked`
// directly on the grounds that it changed nothing — was wrong precisely when the entry it
// deduped against was one the SAME pass had just added and then failed to persist.
//
// `persisted` is the shell's OBSERVATION that the project's stored bank state now reads
// back as what the pass wrote (ReaSamplerSession::saveToActiveProject, which proves that
// one key via extStateWriteLanded) — not that a save was merely attempted. Every sentence
// derived from `Banked` is bounded by that; it says nothing about the project's OTHER keys
// and nothing about the .rpp on disk, which REAPER writes on the project's own save.
BakeLanding bakeLandingAfterPersist(BakeLanding landing, bool persisted);
// Whether the write a verdict required actually took. Three states, not two: the read-back
@@ -211,8 +217,9 @@ BakeLanding bakeLandingAfterPersist(BakeLanding landing, bool persisted);
// user from "it did not land".
enum class BakeWriteProof {
Confirmed, // the key read back as exactly the bytes written (absent/empty for a clear)
Rejected, // it read back as something else — the write did not take
Unknown, // the write-and-check itself failed; neither claim is available
Rejected, // the write did not take: it read back as something else, or it was never
// issued at all
Unknown, // the write was issued and the check itself failed; neither claim available
};
// What ONE scanned key ended the pass in — the per-key half of the tally above, which
@@ -228,29 +235,9 @@ struct BakeKeyOutcome {
// verdict required took
std::string detail; // the outcome's own message, so one line per key is
// self-contained; empty for the verdicts without one, and empty
// wherever the landing clause already carries that same sentence
// wherever the landing clause already makes that same claim
};
// Did the write we just made take? `readBack` is what the key holds afterwards, and
// `nullopt` means ABSENT specifically — an overflowed or failed read is NOT an absence and
// must reach BakeWriteProof::Unknown instead of being folded in here, or a clear would be
// claimed off a value nothing could read. This exists because SetProjExtState's return is
// the size of the WHOLE extname's state — `banks`, `project_guid` and every rsusage_ key
// count toward it, and a bake can only reach a landing in a project whose `banks` is
// already populated — so that return is non-zero whether or not THIS key took the value.
// Reading the key back is the only per-key observation available under a shared extname.
// An empty `written` is a CLEAR, which lands as an absent-or-empty key rather than as those
// bytes.
//
// `[verify — DAW]` byte equality assumes REAPER stores and returns an ext-state value
// verbatim. Two known ways that could be false, both of which would make this report a
// write as Rejected that REAPER in fact took as given: a value REAPER normalises on the
// round trip, and an embedded NUL — SetProjExtState/GetProjExtState are C-string
// transports, so a payload containing one would be truncated on write. Neither produces a
// contradictory pair (the asking instrument reads such a key as Undecodable, itself a
// failure), so both are diagnosis noise rather than a false success.
bool bakeWriteLanded(const std::string& written, const std::optional<std::string>& readBack);
// One console line naming a key and what the pass did with it, ends in '\n'. Printed for
// EVERY enumerated key, answered or not, because the counts above cannot tell an instance
// which key was its own — and the key carries the asking instance's guid.
+30 -3
View File
@@ -1,7 +1,8 @@
#pragma once
// ext_state_read — the GetProjExtState grow-loop retry policy, shared by the
// extension's persist/usage-scan shells and the instrument's bridge so the
// retry/termination rules cannot drift between them.
// ext_state_read — the GetProjExtState grow-loop retry policy AND its peer, the
// read-back verdict that says whether a write took, shared by the extension's
// persist/usage-scan/landing shells and the instrument's bridge so neither rule
// can drift between them.
//
// GetProjExtState writes into a caller-supplied buffer with no query-the-size
// call, so a large value must be read by growing a buffer until it fits
@@ -21,6 +22,7 @@
// in a lambda.
#include <cstddef>
#include <optional>
#include <string>
#include <vector>
@@ -62,4 +64,29 @@ GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) {
return result;
}
// Did the write we just made take? `readBack` is what the key holds afterwards, and
// `nullopt` means ABSENT specifically — an Overflow read is NOT an absence and the caller
// must route it to its own "could not check" answer rather than folding it in here, or a
// clear would be claimed off a value nothing could read. This exists because
// SetProjExtState's return is the size of the WHOLE extname's state: `banks`,
// `project_guid` and every rsusage_/rsbake_ key count toward it, so it is non-zero whenever
// ANY key under the extname holds a value — keys this write never touched included. On the
// bake's landing path, where `banks` is necessarily already populated (the landing found
// its source there), it is non-zero unconditionally. That return therefore cannot separate
// a key that took the value from one that did not, and re-reading the key is the only
// per-key observation available under a shared extname. An empty `written` is a CLEAR,
// which lands as an absent-or-empty key rather than as those bytes.
//
// `[verify — DAW]` byte equality assumes REAPER stores and returns an ext-state value
// verbatim. Two known ways that could be false: a value REAPER normalises on the round
// trip, and an embedded NUL (SetProjExtState/GetProjExtState are C-string transports, so a
// payload containing one is truncated on write). Both fail in the safe direction — a write
// REAPER did take reads back as unconfirmed, never the reverse — so neither can turn an
// unconfirmed write into a claimed success.
inline bool extStateWriteLanded(const std::string& written,
const std::optional<std::string>& readBack) {
if (written.empty()) return !readBack || readBack->empty();
return readBack && *readBack == written;
}
} // namespace reasampler::wire
+9 -1
View File
@@ -57,7 +57,7 @@ detail not covered there:
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::bakeWriteLanded`), because `SetProjExtState`'s return describes the whole extname's state and cannot speak for one key; an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that itself failed or overflowed reports Unknown, never a rejection, because "we could not check" is a different claim from "it did not land". Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing the project would not take is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph.
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing the project would not take is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph.
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
@@ -90,3 +90,11 @@ detail not covered there:
- **Realtime is the one capture path that accepts a multi-track selection**, and it is
correct to: its per-source-track sends sum in the one temp track, which is a real mix
rather than a stem collapse. The offline refusal above does not apply to it.
- **`bake_land`'s panel refresh and its generation bump can disagree after a throw.**
The refresh rides `bookChanged` outside the guarded scan, while the bump sits inside
it — so a pass that landed an entry and then threw before reaching the persist block
repaints the docked panel from the in-memory book without having bumped the bank
generation, and other open instances stay on the old generation until the next bump.
Accepted: the panel showing what the pass actually did is the more useful of the two,
and the next bank mutation reconciles it. Do not "fix" it by moving the refresh inside
the try — that would trade a stale generation for a stale panel.
+20 -13
View File
@@ -130,9 +130,11 @@ private:
// One entry's write-back, and the proof of whether it took. Guarded whole: it encodes into
// a fresh string and readKey grows a buffer toward 16 MB, and a throw escaping here would
// skip the ShowConsoleMsg for EVERY key — the silent console this action exists to make
// impossible. A throw is `Unknown` rather than a failure because the write may well have
// landed before the check did.
// impossible. Which proof a throw yields turns on whether SetProjExtState had run: before
// it, the write is KNOWN not to have been made (`Rejected`); after it, the write may well
// have landed and only the check failed (`Unknown`).
wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
bool issued = false;
try {
std::string value; // empty = clear the key
if (entry.write == ScannedKey::Write::Answer) {
@@ -146,7 +148,8 @@ wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
entry.outcome = refuseBake(
BakeStatus::Failed,
"the bake reached the bank in memory, but this pass's persist did not "
"report success, so this answer cannot promise a reload will find it",
"confirm its bank write, so this answer cannot promise a reload will "
"find it",
entry.outcome.generation);
// `detail` left empty on purpose: describeLanding's Unpersisted clause is
// that sentence's one home on the console side, and the message above is
@@ -158,16 +161,16 @@ wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
value = wire::encodeBakeOutcome(entry.outcome);
}
SetProjExtState(entry.proj, kProjExtNamespace(), entry.key.c_str(), value.c_str());
// SetProjExtState's own return is the size of the whole extname's state, which
// `banks` alone keeps non-zero in every case a bake can reach, so only re-reading
// THIS key can speak for it. An oversized read proves nothing either way — folding
// it into bakeWriteLanded's absent case would report a clear off unread bytes.
issued = true;
// Only re-reading THIS key can speak for it (wire::extStateWriteLanded owns why).
// An oversized read proves nothing either way — folding it into that predicate's
// absent case would report a clear off unread bytes.
const KeyRead back = readKey(entry.proj, entry.key);
if (back.oversized) return wire::BakeWriteProof::Unknown;
return wire::bakeWriteLanded(value, back.value) ? wire::BakeWriteProof::Confirmed
: wire::BakeWriteProof::Rejected;
return wire::extStateWriteLanded(value, back.value) ? wire::BakeWriteProof::Confirmed
: wire::BakeWriteProof::Rejected;
} catch (...) {
return wire::BakeWriteProof::Unknown;
return issued ? wire::BakeWriteProof::Unknown : wire::BakeWriteProof::Rejected;
}
}
@@ -193,8 +196,12 @@ void RunResampleBake(ReaSamplerSession& session) {
std::vector<ScannedKey> scanned;
wire::BakeScanTally tally; // every verdict below is counted, skips included
std::string aborted; // set only when the scan itself threw
bool persisted = false; // the pass's ONE persist reported success
bool persisted = false; // the pass's ONE persist confirmed its bank write
bool bookChanged = false; // some landing added or refreshed an entry
// A local, not a tally field: an entry counted here can still be re-answered as a
// failure by the write-back loop below, so this is a persist GATE and never a count
// anything reports.
bool anyLanded = false;
// The scan allocates outside attemptLanding's own guards too (readKey grows a buffer
// toward 16 MB, every container here allocates), and a throw that escaped would discard
// the answers already buffered — the exact no-answer-with-a-silent-console this action
@@ -253,7 +260,7 @@ void RunResampleBake(ReaSamplerSession& session) {
request->generation);
} else {
LandingAttempt attempt = attemptLanding(session, open.dir, *request);
if (attempt.outcome.status == BakeStatus::Ok) ++tally.landed;
if (attempt.outcome.status == BakeStatus::Ok) anyLanded = true;
if (attempt.changedBook) bookChanged = true;
// Never Banked here, on ANY path: the pass's persist has not run yet,
// and bakeLandingAfterPersist is the one place that word is earned. A
@@ -273,7 +280,7 @@ void RunResampleBake(ReaSamplerSession& session) {
}
}
if (tally.landed > 0) {
if (anyLanded) {
// A dedup hit rides the persist too, because that is the only observation that
// can promote its landing — but it added nothing, so it earns neither the
// refresh every open instance pays for nor an undo point named after a bake
+1 -1
View File
@@ -103,7 +103,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
+6 -1
View File
@@ -186,8 +186,13 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
const std::string key = bakeKeyFor(instanceGuid);
RequestKeyGuard keyGuard(bridge, key);
// The bridge PROVES this by reading the key back, so a false here is real: the
// extension would find nothing to land. It does not say which of the three ways failed,
// so neither does this sentence.
if (!bridge.writeBakeExtState(key, wire::encodeBakeRequest(request)))
return fail("could not publish the bake request");
return fail("the bake request under " + key +
" could not be confirmed -- it was either never written or did not "
"read back as written, so the extension has nothing to land");
// Synchronous: the extension's landing runs to completion inside this call and writes
// its outcome back over the same key before returning.
+19 -9
View File
@@ -2,6 +2,8 @@
#include "shell/instrument/reaper_bridge.h"
#include <optional>
#include <string>
#include <vector>
#include "core/instrument/map/bridge_marshal.h"
@@ -112,7 +114,9 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
bool ReaperBridge::writeGuarded(const std::string& key, const std::string& value,
const char* requiredPrefix) {
if (!setProjExtState_ || !hostApp_) return false;
// getProjExtState_ is required too: without it the write could not be proven, and an
// unprovable write may not be reported as a landed one.
if (!setProjExtState_ || !getProjExtState_ || !hostApp_) return false;
// Read-only-BANK guard: this module writes the two sanctioned per-instance prefixes
// and nothing else. Any other key is refused rather than widening the instrument's
// write surface (banks/view/tail/assign stay extension-owned).
@@ -121,14 +125,20 @@ bool ReaperBridge::writeGuarded(const std::string& key, const std::string& value
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
// SetProjExtState returns the size of the extname's state — after storing a
// non-empty value that's necessarily > 0, so <= 0 means the write did not land (the
// publish path retries next reload tick; a silent drop would leave holds unprotected).
// A deliberate CLEAR (empty value) shrinks the state and can legitimately return 0,
// so it is reported as landed.
const int rv =
setProjExtState_(proj, kProjExtNamespace(), key.c_str(), value.c_str());
return value.empty() ? true : rv > 0;
// The return is DELIBERATELY discarded: it describes the whole extname's state, not
// this key, so testing it was a guard that could not fire for the bake publish
// (wire::extStateWriteLanded owns the reasoning). The read-back below is the proof.
setProjExtState_(proj, kProjExtNamespace(), key.c_str(), value.c_str());
const auto back = wire::readProjExtStateGrowing([&](char* buf, int cap) {
return getProjExtState_(proj, kProjExtNamespace(), key.c_str(), buf, cap);
});
if (back.status == wire::GrowingExtStateRead::Status::Overflow)
return false; // could not check -> report unconfirmed, never a claimed success
return wire::extStateWriteLanded(
value, back.status == wire::GrowingExtStateRead::Status::Complete
? std::optional<std::string>(back.value)
: std::nullopt);
}
bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
+8 -2
View File
@@ -47,10 +47,16 @@ public:
// The instrument's TWO sanctioned ext-state write surfaces, each accepting exactly one
// key prefix and refusing every other key. That structural refusal is what keeps the
// read-only-BANK invariant intact — banks/view/tail/assign stay unwritable from here —
// and neither payload is bank state. Both return true iff the write landed (the
// SetProjExtState return is checked) and neither is RT-safe: the call sites are the
// and neither payload is bank state. Neither is RT-safe: the call sites are the
// off-audio-thread reload path and the editor's UI tick.
//
// Both return true iff the key READ BACK as exactly the value written (an empty value
// is a clear, which lands as an absent-or-empty key). SetProjExtState's own return
// cannot answer that — wire::extStateWriteLanded owns why, and testing it here was a
// guard that could never fire. A `false` does not distinguish a write that was never
// issued (unconnected host, refused prefix) from one that did not take or could not be
// checked; a caller must not name one of the three.
//
// Neither marks the project dirty. A usage change always rides a component-state change
// that already does; a bake request is transient and is cleared in the same tick.
+1 -1
View File
@@ -53,7 +53,7 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
## Modules
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `saveToActiveProject` PROVES its `banks` write by reading that key back (`wire::extStateWriteLanded`) and returns that verdict — the sibling keys are written but not verified, so its `true` means "the bank state is in the project", never "everything persisted". `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
+18 -1
View File
@@ -182,7 +182,24 @@ bool ReaSamplerSession::saveToActiveProject() {
instrument::map::formatBankGeneration(bankGeneration_).c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
return true;
// Prove the ONE key a caller's answer hinges on. Each SetProjExtState above returns the
// size of the whole extname's state, which the six writes here keep non-zero between
// them, so no single one of those returns can speak for `banks`
// (wire::extStateWriteLanded owns the reasoning). The sibling keys stay unobserved and
// no caller claims otherwise; `banks` is the one whose absence would make a landed
// capture vanish on reload.
const wire::GrowingExtStateRead back = wire::readProjExtStateGrowing(
[&](char* buf, int cap) {
return GetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtBanksKey, buf, cap);
});
if (back.status == wire::GrowingExtStateRead::Status::Overflow)
return false; // could not check -> do not claim; never folded in as an absence
return wire::extStateWriteLanded(
banksJson, back.status == wire::GrowingExtStateRead::Status::Complete
? std::optional<std::string>(back.value)
: std::nullopt);
}
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
+10 -2
View File
@@ -108,8 +108,16 @@ public:
void bumpBankGeneration() { ++bankGeneration_; }
// Serializes book/view/tail to ext state, clears the retired legacy
// `bank_index` key. No-ops with no active/saved project. Returns true iff
// a persist happened, so a caller can skip an undo block when nothing was written.
// `bank_index` key. No-ops with no active/saved project.
//
// Returns true iff the `banks` key READ BACK as exactly what this call wrote —
// the only per-key observation available under a shared extname
// (wire::extStateWriteLanded owns why SetProjExtState's own return cannot
// answer it). A false therefore covers four things without distinguishing
// them: no active project, an unsaved project, a rejected write, and a
// read-back that could not complete. The sibling keys (view/tail/ledger/
// version/generation) are written but NOT verified, so no caller may read
// this as "everything persisted" — only as "the bank state is in the project".
bool saveToActiveProject();
// Report-only prune dry-run: feeds the pure core with (present, referenced,
+79 -32
View File
@@ -9,11 +9,13 @@
// degrading to Failed rather than to Ok; the action lookup name's leading underscore and
// its channel fork; the two key classifiers each end reads the shared key through; the
// write-back verdict, driven by a modelled key store that accepts or drops the write; and
// the persist/upgrade state machine that is the ONLY route to a Banked landing.
// the persist/upgrade state machine that is the ONLY route to a Banked landing, including
// the reachability of its Unpersisted limb at both persist outcomes.
#include "../src/core/wire/bake_wire.h"
#include "../src/core/version/app_version.h"
#include "../src/core/wire/ext_state_read.h" // extStateWriteLanded (the write-proof peer)
#include <cstdint>
#include <cstdio>
@@ -31,8 +33,8 @@ static int g_fail = 0;
// here and used by both blocks below that need it, so the test suite does not become a
// third place the sentence lives.
static const std::string kUnpersistedAnswer =
"the bake reached the bank in memory, but this pass's persist did not report success, "
"so this answer cannot promise a reload will find it";
"the bake reached the bank in memory, but this pass's persist did not confirm its bank "
"write, so this answer cannot promise a reload will find it";
int main() {
// --- The exact bytes on the wire -------------------------------------------------
@@ -412,13 +414,7 @@ int main() {
clean.keysFound = 1;
clean.activeTabKeys = 1;
clean.answered = 1;
clean.landed = 1;
CHECK(describeBakeScan(clean).empty());
// A refusal is still an answer, so it silences the summary the same way a landing
// does — the refusal's own sentence has already been printed.
BakeScanTally refusedOne = clean;
refusedOne.landed = 0;
CHECK(describeBakeScan(refusedOne).empty());
// THE regression: some OTHER key was answered while ours was skipped. Under the old
// `answered > 0` gate this printed nothing at all, and the instrument then told the
@@ -557,11 +553,11 @@ int main() {
// The outcome's own reason rides the SAME line: one self-contained line per key,
// rather than a keyed verdict and an unkeyed reason the reader has to pair up.
CHECK(ok.find("(added as a distinct capture)") != std::string::npos);
// Banked claims the persist FLAG and nothing more, never that the .rpp already
// holds it — saveToActiveProject issues its SetProjExtState calls without reading
// their returns, and project ext state reaches the file on the project's own save.
CHECK(ok.find("persist reported success") != std::string::npos);
CHECK(ok.find("once the project is saved") != std::string::npos);
// The sentence names the OBSERVATION behind the flag (see BakeLanding) and stops
// there — it may not claim the .rpp on disk already holds the entry.
CHECK(ok.find("read back as exactly what this pass wrote") != std::string::npos);
CHECK(ok.find(".rpp") == std::string::npos);
CHECK(ok.find("carries it") == std::string::npos);
// A landing whose answer write was REJECTED — the state the instrument reads as
// Unanswered. This line is the only place it is ever named.
@@ -589,7 +585,7 @@ int main() {
unpersisted.landing = BakeLanding::Unpersisted;
const std::string memoryOnly = describeBakeKey(key, unpersisted);
CHECK(memoryOnly.find("IN MEMORY ONLY") != std::string::npos);
CHECK(memoryOnly.find("persist did not report success") != std::string::npos);
CHECK(memoryOnly.find("persist did not confirm its bank write") != std::string::npos);
CHECK(memoryOnly != ok);
// It may NOT claim what the project's saved state holds: the persist can fail
// before writing anything or throw part-way, and a dedup hit's target may have
@@ -633,9 +629,12 @@ int main() {
BakeKeyOutcome clearLost = cleared;
clearLost.proof = BakeWriteProof::Rejected;
const std::string stuck = describeBakeKey(key, clearLost);
CHECK(stuck.find("the clear could NOT be read back") != std::string::npos);
CHECK(stuck.find("the clear did NOT take") != std::string::npos);
CHECK(stuck.find("next pass will see it again") != std::string::npos);
CHECK(stuck.find("it was cleared") == std::string::npos);
// Rejected also covers a clear that was never issued, so the line may not describe
// a read that did not happen.
CHECK(stuck.find("read back") == std::string::npos);
// And the clear's third state, same as an answer's: unchecked is not disproven.
BakeKeyOutcome clearUnchecked = cleared;
clearUnchecked.proof = BakeWriteProof::Unknown;
@@ -739,12 +738,11 @@ int main() {
}
// --- A rejected write is REACHABLE, and it is what the tally and the line come from ---
// The shell used to judge this by SetProjExtState's return, which is the size of the
// WHOLE extname's state — `banks` alone keeps that non-zero in every case a bake can
// reach, so `writeFailed` could not be produced at all and the sentence for it was dead
// code. The verdict is now the read-back below, which a store that drops the write does
// produce. The store is modelled here; the shell binds these same two calls to
// SetProjExtState and its existing grow-loop GetProjExtState read.
// Judging this by SetProjExtState's return made `writeFailed` unproducible on the
// landing path and its sentence dead code (extStateWriteLanded owns why). The verdict is
// the read-back, which a store that drops the write does produce. The store is modelled
// here; the three shells bind these same two calls to SetProjExtState and the
// grow-loop read.
{
struct FakeKeyStore {
std::map<std::string, std::string> values;
@@ -778,7 +776,7 @@ int main() {
rejecting.values[key] = encodeBakeRequest(pending);
rejecting.dropWrites = true;
rejecting.write(key, answer);
CHECK(!bakeWriteLanded(answer, rejecting.read(key)));
CHECK(!extStateWriteLanded(answer, rejecting.read(key)));
// ...which is exactly what the ASKING end reads as a no-answer. The two classifiers
// agree about this one state, which is why the extension has to name it.
CHECK(classifyBakeAnswer(rejecting.read(key), pending).kind ==
@@ -790,13 +788,12 @@ int main() {
tally.keysFound = 1;
tally.activeTabKeys = 1;
tally.answered = 1;
tally.landed = 1;
BakeKeyOutcome report;
report.verdict = BakeScanVerdict::Land;
// The shell reaches Banked only through the upgrade — never by assignment.
report.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, true);
report.detail = landedOk.message;
report.proof = bakeWriteLanded(answer, rejecting.read(key))
report.proof = extStateWriteLanded(answer, rejecting.read(key))
? BakeWriteProof::Confirmed
: BakeWriteProof::Rejected;
if (report.proof == BakeWriteProof::Rejected) ++tally.writeFailed;
@@ -811,7 +808,7 @@ int main() {
FakeKeyStore accepting;
accepting.values[key] = encodeBakeRequest(pending);
accepting.write(key, answer);
CHECK(bakeWriteLanded(answer, accepting.read(key)));
CHECK(extStateWriteLanded(answer, accepting.read(key)));
CHECK(classifyBakeAnswer(accepting.read(key), pending).kind ==
BakeAnswerKind::Answered);
BakeScanTally clean = tally;
@@ -823,21 +820,71 @@ int main() {
FakeKeyStore clearing;
clearing.values[key] = encodeBakeRequest(pending);
clearing.write(key, "");
CHECK(bakeWriteLanded("", clearing.read(key)));
CHECK(extStateWriteLanded("", clearing.read(key)));
FakeKeyStore clearDropped;
clearDropped.values[key] = encodeBakeRequest(pending);
clearDropped.dropWrites = true;
clearDropped.write(key, "");
CHECK(!bakeWriteLanded("", clearDropped.read(key)));
CHECK(!extStateWriteLanded("", clearDropped.read(key)));
// Byte equality is the claim the line makes: a truncated or foreign value under the
// key is not the answer we wrote, and an unreadable key proves nothing at all.
CHECK(!bakeWriteLanded(answer, std::optional<std::string>(
CHECK(!extStateWriteLanded(answer, std::optional<std::string>(
answer.substr(0, answer.size() - 1))));
CHECK(!bakeWriteLanded(answer, std::optional<std::string>(
CHECK(!extStateWriteLanded(answer, std::optional<std::string>(
encodeBakeRequest(pending))));
CHECK(!bakeWriteLanded(answer, std::nullopt));
CHECK(!bakeWriteLanded("", std::optional<std::string>("leftover")));
CHECK(!extStateWriteLanded(answer, std::nullopt));
CHECK(!extStateWriteLanded("", std::optional<std::string>("leftover")));
}
// --- The Unpersisted limb is REACHABLE, through the same predicate ------------------
// `persisted` used to be structurally true wherever a landing could observe it: the
// session reported it from SetProjExtState's unread return, and by the time a landing
// exists the only two conditions that return could reflect (no active project, an
// unsaved one) are already excluded by the Land verdict. So the Unpersisted limb, the
// wire answer for it and its console clause were all dead. The session now proves the
// `banks` key the same way the landing proves its own — modelled below at both
// outcomes, so neither branch is a constant.
{
const std::string banksJson = R"({"banks":[{"id":"pool","samples":[]}]})";
// The write REJECTED: the project still holds whatever it held before, which is a
// book without the entry this pass just landed in memory.
const std::optional<std::string> stale{R"({"banks":[]})"};
const bool persistedAfterDrop = extStateWriteLanded(banksJson, stale);
CHECK(!persistedAfterDrop);
BakeKeyOutcome entry;
entry.verdict = BakeScanVerdict::Land;
entry.landing =
bakeLandingAfterPersist(BakeLanding::Unpersisted, persistedAfterDrop);
entry.proof = BakeWriteProof::Confirmed; // the ANSWER wrote fine; the persist did not
CHECK(entry.landing == BakeLanding::Unpersisted);
const std::string line = describeBakeKey("rsbake_0123abcd", entry);
CHECK(line.find("IN MEMORY ONLY") != std::string::npos);
CHECK(line.find("The answer was written back") != std::string::npos);
// ...and that landing is answered as a FAILURE on the wire, so no instance adopts
// an entry a reload would not find.
BakeOutcome answered;
answered.status = BakeStatus::Failed;
answered.message = kUnpersistedAnswer;
answered.generation = 1893456000;
const auto back = decodeBakeOutcome(encodeBakeOutcome(answered));
CHECK(back.has_value() && back->status == BakeStatus::Failed);
CHECK(back.has_value() && back->message == kUnpersistedAnswer);
// The write ACCEPTED, same predicate, same inputs but for the read-back: the limb
// above is a real branch, not a constant.
const bool persistedAfterTake =
extStateWriteLanded(banksJson, std::optional<std::string>(banksJson));
CHECK(persistedAfterTake);
CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, persistedAfterTake) ==
BakeLanding::Banked);
// An unreadable read-back must NOT be folded in as an absence-and-therefore-a-clear:
// the session routes Overflow to false before it ever reaches this predicate, and a
// non-empty write against an absent key is false here regardless.
CHECK(!extStateWriteLanded(banksJson, std::nullopt));
}
// --- Every outcome bake_land actually emits survives the key round trip -------------