Take the read-back back out of the persist; a write verdict belongs only where evidence crosses the plugin boundary

Its false gated six undo points, so an unverified byte-equality assumption could have silently removed Ctrl-Z for a bank mutation that landed.
This commit is contained in:
2026-08-02 13:42:51 -04:00
parent 25390d5253
commit 1c8709e82d
12 changed files with 181 additions and 127 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`, 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.
- `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. Two shells bind it, BOTH on the instrument's per-instance keys: the bake landing's answer/clear write-back and the instrument bridge's prefix-guarded writes. Deliberately NOT the persist, whose return is control flow over undo points — this verdict is a proof value reported to the user, and an observational false there would discard the Ctrl-Z for a bank mutation that landed. 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. 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.
- `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 reported success. What `Banked` claims is exactly that `persisted` input — a saved project was active and the bank write was issued — and 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.
+8 -9
View File
@@ -246,18 +246,17 @@ namespace {
std::string describeLanding(BakeLanding landing) {
switch (landing) {
case BakeLanding::Banked:
// Bounded by what `persisted` actually observed (see BakeLanding): the stored
// bank state read back as this pass wrote it. The trailing clause is an
// Bounded by what `persisted` actually observed (see BakeLanding): a saved
// project was active and the write was issued. 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";
return "landed into the bank, and this pass issued its bank write into the "
"saved project -- save the project to keep it";
case BakeLanding::Unpersisted:
// Says nothing about what the project holds: the persist can fail before
// 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.
// Says nothing about what the project holds: the persist may never have run at
// all, 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 "
"confirm its bank write, so nothing here can say the project carries it";
"report success, 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";
+12 -7
View File
@@ -196,8 +196,8 @@ std::string describeBakeScan(const BakeScanTally& tally);
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 confirm
Banked, // in the book, and the pass's persist confirmed its bank write
Unpersisted, // it reached the in-memory book; the pass's persist did not report success
Banked, // in the book, and the pass issued its bank write into a saved project
};
// The ONE route to `Banked`: every Land verdict's landing is assigned `Unpersisted` and
@@ -205,11 +205,16 @@ enum class BakeLanding {
// 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.
// `persisted` is ReaSamplerSession::saveToActiveProject's return: a saved project was
// active and the bank write was ISSUED into it. That is the whole claim — not that REAPER
// took the value, not that any sibling key was written, and not that the .rpp on disk holds
// it, which REAPER writes on the project's own save.
//
// The `Unpersisted` limb survives that narrowing: under a Land verdict the persist's own
// two refusals are already excluded, so what reaches it is a persist that did not RUN —
// bake_land assigns the landing inside a guarded scan and sets its `persisted` local only
// in the block after it, so a throw between the two leaves a landed entry with the flag
// still false.
BakeLanding bakeLandingAfterPersist(BakeLanding landing, bool persisted);
// Whether the write a verdict required actually took. Three states, not two: the read-back
+20 -7
View File
@@ -1,8 +1,8 @@
#pragma once
// 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.
// ext_state_read — the GetProjExtState grow-loop retry policy (shared by the
// extension's persist/usage-scan/landing shells and the instrument's bridge) AND
// its peer, the read-back verdict that says whether a write took — that one bound
// to the instrument's per-instance keys only, for the reason stated at it below.
//
// 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
@@ -77,12 +77,25 @@ GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) {
// 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.
//
// SCOPE — the per-instance keys the two artifacts exchange across the plugin/extension
// boundary. The one that earns it is the bake's `rsbake_<guid>`: the instrument publishes a
// request, REAPER dispatches the action, the extension answers over the same key, and an
// instance finding nothing there genuinely cannot tell "never written" from "written and
// did not stick" by any other means. (The `rsusage_` publish rides along because it shares
// the bridge's one prefix-guarded writer; its verdict is currently unread.)
//
// Do NOT bind it to a write both made and read by one call in one process, and above all
// not to one whose verdict becomes CONTROL FLOW: this is a PROOF VALUE reported to the
// user, never a decision about what to do next. `saveToActiveProject` is the standing
// counter-example — its return IS control flow, over undo points — so do not reintroduce
// a read-back there.
//
// `[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.
// payload containing one is truncated on write). Either would report a write REAPER did
// take as unconfirmed — tolerable for a proof value, which is the only thing this may
// answer, and the reason the scope above is a hard limit rather than a preference.
inline bool extStateWriteLanded(const std::string& written,
const std::optional<std::string>& readBack) {
if (written.empty()) return !readBack || readBack->empty();