Make a bake's Banked landing reachable only through the persist upgrade

The dedup path claimed it directly, so a key could be answered Ok pointing at an entry a failed persist never wrote. Write-back is now guarded and three-valued, and landing one request split out of the scan pass.
This commit is contained in:
2026-08-02 12:35:06 -04:00
parent 1800bd64c3
commit 0afb885987
10 changed files with 664 additions and 371 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ This directory owns two cross-artifact contracts specifically:
- `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.
- `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.
- `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.
- `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.
+60 -19
View File
@@ -192,7 +192,8 @@ std::string describeBakeScan(const BakeScanTally& t) {
// had the write rejected, would otherwise print nothing at all and leave the asking
// instance with a no-answer and a silent console.
const int unanswered = t.unreadable + t.notARequest + t.staleCleared;
if (t.answered > 0 && unanswered == 0 && t.writeFailed == 0) return {};
if (t.answered > 0 && unanswered == 0 && t.writeFailed == 0 && t.writeUnproven == 0)
return {};
std::string s = "ReaSampler resample: the landing action ran and scanned " +
countOf(t.tabsScanned, "project tab") + ". ";
@@ -225,27 +226,61 @@ std::string describeBakeScan(const BakeScanTally& t) {
s += " " + countOf(t.writeFailed, "answer") +
" could not be written back into the project, so whoever asked for them sees "
"no answer at all.";
if (t.writeUnproven > 0)
s += " " + countOf(t.writeUnproven, "answer") +
" could not be checked after writing, so whether they reached the project is "
"unknown.";
return s + "\n";
}
BakeLanding bakeLandingAfterPersist(BakeLanding landing, bool persisted) {
if (landing == BakeLanding::Unpersisted && persisted) return BakeLanding::Banked;
return landing;
}
namespace {
// The Land verdict's own clause. Separate function so its own fail-closed default cannot
// be swallowed by the caller's switch — an unnamed enumerator returns empty either way.
// The Land verdict's own clause, with its own fail-closed default — an unnamed enumerator
// must not fall out empty and get reported by the caller as an unnamed VERDICT, which is a
// different gap in a different enum.
std::string describeLanding(BakeLanding landing) {
switch (landing) {
case BakeLanding::Banked:
return "landed into the bank";
// 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";
case BakeLanding::Unpersisted:
return "landed into the bank IN MEMORY ONLY -- this pass could not persist it, "
"so the project's saved bank state does not carry it";
// 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.
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";
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";
case BakeLanding::Refused:
return "the landing was refused";
}
return {};
return "a landing state this build has no word for";
}
// The write-back's own clause, tail of every line that made one.
std::string describeProof(BakeWriteProof proof) {
switch (proof) {
case BakeWriteProof::Confirmed:
return ". The answer was written back.\n";
case BakeWriteProof::Rejected:
return ". The answer could NOT be written back -- the instance that asked will "
"report no answer.\n";
case BakeWriteProof::Unknown:
break;
}
return ". Whether the answer reached the key is unknown -- the write-and-check itself "
"failed.\n";
}
} // namespace
@@ -263,15 +298,24 @@ std::string describeBakeKey(const std::string& key, const BakeKeyOutcome& outcom
return head +
"skipped: it holds something other than a pending request -- an answer "
"nobody has collected, or a wire this build does not read.\n";
case BakeScanVerdict::ClearStale:
case BakeScanVerdict::ClearStale: {
// The clear is a write like any other, so it is claimed only where it was read
// back: an unconfirmed one leaves the request standing for the next pass.
return head + (outcome.writeConfirmed
? "past the age bound with no reader left, so it was cleared "
"unanswered.\n"
: "past the age bound with no reader left, but the clear "
"could NOT be read back -- the key still holds it and the "
"next pass will see it again.\n");
// back: an unconfirmed one leaves a value standing for the next pass.
const std::string aged = head + "past the age bound with no reader left";
switch (outcome.proof) {
case BakeWriteProof::Confirmed:
return aged + ", so it was cleared unanswered.\n";
case BakeWriteProof::Rejected:
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";
case BakeWriteProof::Unknown:
break;
}
return aged +
"; whether the clear took is unknown -- the write-and-check itself "
"failed.\n";
}
case BakeScanVerdict::RefuseWrongProject:
clause = "refused";
break;
@@ -285,10 +329,7 @@ std::string describeBakeKey(const std::string& key, const BakeKeyOutcome& outcom
if (clause.empty()) return head + "a verdict this build has no word for.\n";
std::string s = head + clause;
if (!outcome.detail.empty()) s += " (" + outcome.detail + ")";
return s + (outcome.writeConfirmed
? ". The answer was written back.\n"
: ". The answer could NOT be written back -- the instance that asked "
"will report no answer.\n");
return s + describeProof(outcome.proof);
}
bool bakeWriteLanded(const std::string& written, const std::optional<std::string>& readBack) {
+50 -21
View File
@@ -175,10 +175,12 @@ struct BakeScanTally {
int staleCleared = 0; // ClearStale
int answered = 0; // an outcome was QUEUED for write-back (a landing OR a
// refusal) — pass-wide, not per key, and not proof the write
// reached the project; `writeFailed` is that
int writeFailed = 0; // of `answered`, the ones whose value could NOT be read back
// from their key afterwards — an asking instance sees these as
// no answer at all
// reached the project; `writeFailed`/`writeUnproven` are that
int writeFailed = 0; // of `answered`, the ones whose value read back as something
// other than what was written — an asking instance sees these
// 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
};
@@ -188,14 +190,29 @@ struct BakeScanTally {
// evidence the landing never ran; nothing here can observe that, and no caller may say it.
std::string describeBakeScan(const BakeScanTally& tally);
// How far a `Land` verdict actually got. Only `Banked` is a landing the project still holds
// after a reload: the book lives in memory and a pass that could not persist has changed
// nothing the .rpp will carry.
// 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.
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, but the pass's persist did not happen
Banked, // in the book AND persisted into the project
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
};
// 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.
BakeLanding bakeLandingAfterPersist(BakeLanding landing, bool persisted);
// Whether the write a verdict required actually took. Three states, not two: the read-back
// that proves it can itself fail, and "we could not check" is a different thing to tell the
// 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
};
// What ONE scanned key ended the pass in — the per-key half of the tally above, which
@@ -206,20 +223,32 @@ struct BakeKeyOutcome {
bool oversized = false; // IgnoreUnreadable only: the value exceeded the read ceiling
// rather than reading back empty
BakeLanding landing = BakeLanding::Refused; // Land only
bool writeConfirmed = false; // Land / RefuseWrongProject / ClearStale: the write this
// verdict required — an outcome, or a clear — was READ
// BACK from the key afterwards (see bakeWriteLanded)
std::string detail; // the outcome's own message, so one line per key is
// self-contained; empty for the verdicts without one
BakeWriteProof proof = BakeWriteProof::Unknown; // Land / RefuseWrongProject /
// ClearStale: whether the write this
// 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
};
// Did the write we just made take? `readBack` is what the key holds afterwards (nullopt =
// absent or unreadable). 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.
// 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