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
View File
@@ -11,6 +11,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/capture/capture_orchestrator.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture_orchestrator.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp ${REASAMPLER_SRC_DIR}/shell/capture/capture_batch.cpp
${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp ${REASAMPLER_SRC_DIR}/shell/capture/bake_land.cpp
${REASAMPLER_SRC_DIR}/shell/capture/bake_landing.cpp
${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp ${REASAMPLER_SRC_DIR}/shell/capture/scope_resolve.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp ${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp ${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp
+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. - `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. - `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. - `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. - `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. - `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 // had the write rejected, would otherwise print nothing at all and leave the asking
// instance with a no-answer and a silent console. // instance with a no-answer and a silent console.
const int unanswered = t.unreadable + t.notARequest + t.staleCleared; 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 " + std::string s = "ReaSampler resample: the landing action ran and scanned " +
countOf(t.tabsScanned, "project tab") + ". "; countOf(t.tabsScanned, "project tab") + ". ";
@@ -225,27 +226,61 @@ std::string describeBakeScan(const BakeScanTally& t) {
s += " " + countOf(t.writeFailed, "answer") + s += " " + countOf(t.writeFailed, "answer") +
" could not be written back into the project, so whoever asked for them sees " " could not be written back into the project, so whoever asked for them sees "
"no answer at all."; "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"; return s + "\n";
} }
BakeLanding bakeLandingAfterPersist(BakeLanding landing, bool persisted) {
if (landing == BakeLanding::Unpersisted && persisted) return BakeLanding::Banked;
return landing;
}
namespace { namespace {
// The Land verdict's own clause. Separate function so its own fail-closed default cannot // The Land verdict's own clause, with its own fail-closed default — an unnamed enumerator
// be swallowed by the caller's switch — an unnamed enumerator returns empty either way. // 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) { std::string describeLanding(BakeLanding landing) {
switch (landing) { switch (landing) {
case BakeLanding::Banked: 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: case BakeLanding::Unpersisted:
return "landed into the bank IN MEMORY ONLY -- this pass could not persist it, " // Says nothing about what the project holds: the persist can fail before
"so the project's saved bank state does not carry it"; // 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: case BakeLanding::Partial:
return "the landing failed after it had begun writing -- it may have left a " return "the landing failed after it had begun writing -- it may have left a "
"file in the bank folder and an entry in memory"; "file in the bank folder and an entry in memory";
case BakeLanding::Refused: case BakeLanding::Refused:
return "the landing was 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 } // namespace
@@ -263,15 +298,24 @@ std::string describeBakeKey(const std::string& key, const BakeKeyOutcome& outcom
return head + return head +
"skipped: it holds something other than a pending request -- an answer " "skipped: it holds something other than a pending request -- an answer "
"nobody has collected, or a wire this build does not read.\n"; "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 // 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. // back: an unconfirmed one leaves a value standing for the next pass.
return head + (outcome.writeConfirmed const std::string aged = head + "past the age bound with no reader left";
? "past the age bound with no reader left, so it was cleared " switch (outcome.proof) {
"unanswered.\n" case BakeWriteProof::Confirmed:
: "past the age bound with no reader left, but the clear " return aged + ", so it was cleared unanswered.\n";
"could NOT be read back -- the key still holds it and the " case BakeWriteProof::Rejected:
"next pass will see it again.\n"); 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: case BakeScanVerdict::RefuseWrongProject:
clause = "refused"; clause = "refused";
break; 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"; if (clause.empty()) return head + "a verdict this build has no word for.\n";
std::string s = head + clause; std::string s = head + clause;
if (!outcome.detail.empty()) s += " (" + outcome.detail + ")"; if (!outcome.detail.empty()) s += " (" + outcome.detail + ")";
return s + (outcome.writeConfirmed return s + describeProof(outcome.proof);
? ". The answer was written back.\n"
: ". The answer could NOT be written back -- the instance that asked "
"will report no answer.\n");
} }
bool bakeWriteLanded(const std::string& written, const std::optional<std::string>& readBack) { bool bakeWriteLanded(const std::string& written, const std::optional<std::string>& readBack) {
+49 -20
View File
@@ -175,10 +175,12 @@ struct BakeScanTally {
int staleCleared = 0; // ClearStale int staleCleared = 0; // ClearStale
int answered = 0; // an outcome was QUEUED for write-back (a landing OR a 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 // refusal) — pass-wide, not per key, and not proof the write
// reached the project; `writeFailed` is that // reached the project; `writeFailed`/`writeUnproven` are that
int writeFailed = 0; // of `answered`, the ones whose value could NOT be read back int writeFailed = 0; // of `answered`, the ones whose value read back as something
// from their key afterwards — an asking instance sees these as // other than what was written — an asking instance sees these
// no answer at all // 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 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. // evidence the landing never ran; nothing here can observe that, and no caller may say it.
std::string describeBakeScan(const BakeScanTally& tally); std::string describeBakeScan(const BakeScanTally& tally);
// How far a `Land` verdict actually got. Only `Banked` is a landing the project still holds // How far a `Land` verdict actually got. `Banked` is reachable ONLY through
// after a reload: the book lives in memory and a pass that could not persist has changed // bakeLandingAfterPersist below — a landing may not claim the word on its own, because
// nothing the .rpp will carry. // nothing a landing can see says whether the pass's persist ran.
enum class BakeLanding { enum class BakeLanding {
Refused, // nothing was written and the book is untouched 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 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 Unpersisted, // it reached the in-memory book; the pass's persist did not report success
Banked, // in the book AND persisted into the project 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 // 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 bool oversized = false; // IgnoreUnreadable only: the value exceeded the read ceiling
// rather than reading back empty // rather than reading back empty
BakeLanding landing = BakeLanding::Refused; // Land only BakeLanding landing = BakeLanding::Refused; // Land only
bool writeConfirmed = false; // Land / RefuseWrongProject / ClearStale: the write this BakeWriteProof proof = BakeWriteProof::Unknown; // Land / RefuseWrongProject /
// verdict required — an outcome, or a clear — was READ // ClearStale: whether the write this
// BACK from the key afterwards (see bakeWriteLanded) // verdict required took
std::string detail; // the outcome's own message, so one line per key is std::string detail; // the outcome's own message, so one line per key is
// self-contained; empty for the verdicts without one // 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 = // Did the write we just made take? `readBack` is what the key holds afterwards, and
// absent or unreadable). This exists because SetProjExtState's return is the size of the // `nullopt` means ABSENT specifically — an overflowed or failed read is NOT an absence and
// WHOLE extname's state — `banks`, `project_guid` and every rsusage_ key count toward it, // must reach BakeWriteProof::Unknown instead of being folded in here, or a clear would be
// and a bake can only reach a landing in a project whose `banks` is already populated — so // claimed off a value nothing could read. This exists because SetProjExtState's return is
// that return is non-zero whether or not THIS key took the value. Reading the key back is // the size of the WHOLE extname's state — `banks`, `project_guid` and every rsusage_ key
// the only per-key observation available under a shared extname. An empty `written` is a // count toward it, and a bake can only reach a landing in a project whose `banks` is
// CLEAR, which lands as an absent-or-empty key rather than as those bytes. // 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); 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 // One console line naming a key and what the pass did with it, ends in '\n'. Printed for
+2 -1
View File
@@ -57,7 +57,8 @@ 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_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`). - `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). - `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: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded, 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. 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 rejected, 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. 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. The undo block is stack RAII (`UndoBlock`), and the landing is split into a non-mutating `prepareLanding` and a mutating `commitLanding` under separate catches — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial, and neither an exception nor an early return can leave an undo block open or discard the buffered answers. 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. 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". 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. - `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_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. - `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. - `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.
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26). - `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
+89 -306
View File
@@ -1,30 +1,22 @@
// bake_land.cpp — see bake_land.h. main.cpp owns the API pointers; this TU gets them // bake_land.cpp — see bake_land.h. main.cpp owns the API pointers; this TU gets them
// extern. REAPER symbols used here (EnumProjects, EnumProjExtState, GetProjExtState, // extern. REAPER symbols used here (EnumProjects, EnumProjExtState, GetProjExtState,
// SetProjExtState, ShowConsoleMsg, Undo_BeginBlock2/EndBlock2) are verified against // SetProjExtState, ShowConsoleMsg, Undo_BeginBlock2/EndBlock2) are verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h. // vendor/reaper-sdk/sdk/reaper_plugin_functions.h. Landing one request is bake_landing's.
#include "shell/capture/bake_land.h" #include "shell/capture/bake_land.h"
#include <cstdint> #include <cstdint>
#include <ctime> #include <ctime>
#include <exception>
#include <filesystem>
#include <fstream>
#include <optional> #include <optional>
#include <string> #include <string>
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp #include "core/capture/capture_paths.h" // projectDirOfRpp
#include "core/capture/wav_codec.h" // parseWavLayout / hashWavContent
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/model/resample_name.h" // the iteration-chain display name
#include "core/tracking/tracking_authority.h" // resampleLanding (replace vs add-distinct)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "core/wire/bake_wire.h" #include "core/wire/bake_wire.h"
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop) #include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop)
#include "ext_keys.h" #include "ext_keys.h"
#include "shell/capture/bake_landing.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh #include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/persist/session.h" #include "shell/persist/session.h"
@@ -44,256 +36,10 @@ namespace reasampler::capture {
namespace { namespace {
namespace fs = std::filesystem;
using model::AddResult;
using model::Sample;
using model::nextIterationName;
using wire::BakeOutcome; using wire::BakeOutcome;
using wire::BakeRequest; using wire::BakeRequest;
using wire::BakeStatus; using wire::BakeStatus;
BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generation) {
BakeOutcome out;
out.status = status;
out.message = std::move(message);
out.generation = generation;
return out;
}
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
return f.good();
}
// The bank holding `sampleId`, plus the entry itself. nullptr when no bank holds it.
const Sample* findSourceSample(const BankBook& book, const std::string& sampleId,
std::string& bankIdOut) {
for (const Bank& b : book.banks()) {
if (const Sample* s = b.index.query(sampleId)) {
bankIdOut = b.id;
return s;
}
}
return nullptr;
}
// Everything a landing DECIDES, before it touches disk or book: the staged WAV read and
// hash, the source lookup, replace-vs-add, and the dedup short-circuit. Split from the
// commit below so the two halves' throws can be told apart — a prepare that threw left the
// project untouched, and only a commit that threw may have written something.
struct PreparedLanding {
std::optional<BakeOutcome> settled; // a refusal or a dedup hit: nothing left to commit
BakeOutcome outcome; // the shape a successful commit completes
Sample landed; // the entry the commit indexes
std::vector<std::uint8_t> bytes; // the staged file, already read
std::string bankId;
std::string destDir;
std::string destPath;
bool replace = false;
};
PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
PreparedLanding prep;
if (projectDir.empty()) {
prep.settled = refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location",
request.generation);
return prep;
}
prep.bytes = util::readFileBytes(request.stagedFilePath);
if (prep.bytes.empty()) {
prep.settled = refuse(BakeStatus::StagedMissing, "the staged render was unreadable",
request.generation);
return prep;
}
const WavLayout layout = parseWavLayout(prep.bytes);
if (!layout.valid || layout.frameCount() == 0) {
prep.settled = refuse(BakeStatus::StagedMissing,
"the staged render is not a usable WAV", request.generation);
return prep;
}
BankBook& book = session.book();
const Sample* source = findSourceSample(book, request.sourceSampleId, prep.bankId);
if (!source) {
prep.settled = refuse(BakeStatus::NoSource,
"the resampled capture is not in any bank", request.generation);
return prep;
}
// Copied, not aliased: every mutation the commit makes invalidates the book's pointers.
const Sample sourceCopy = *source;
const tracking::Landing landing = tracking::resampleLanding(
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
prep.replace = (landing == tracking::Landing::Replace);
// WAV-aware hash: the bank's dedup key, and — on the add path only — the reason a
// byte-identical bake yields no second entry. Replace never dedups, matching
// updateSampleInPlace's own contract: an in-place refresh is not an insert.
const std::string contentHash = hashWavContent(prep.bytes);
prep.outcome.generation = request.generation;
prep.outcome.rootNote = request.rootNote;
prep.outcome.channelCount = static_cast<int>(layout.channelCount);
prep.outcome.replaced = prep.replace;
if (!prep.replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(prep.bankId)) {
if (const Sample* existing = index->findByHash(contentHash)) {
// Dedup BEFORE the disk write: this bake is bytes the bank already holds,
// so it re-points at that entry rather than depositing an unreferenced
// twin for the prune to reclaim later.
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = existing->id;
out.relativePath = existing->relativePath;
out.displayName = existing->displayName;
out.message = "identical to an existing capture -- pointed at it";
prep.settled = std::move(out);
return prep;
}
}
}
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
// Seconds alone are not unique enough here: two bakes of the same source inside one
// second would derive the same file name, and the second would overwrite a file the
// first had just indexed. The content hash separates them, and leaves a re-bake of
// identical bytes idempotent rather than duplicated.
const std::string uniqueTag =
std::to_string(nowSec) +
(contentHash.empty() ? std::string{} : "-" + contentHash.substr(0, 8));
const std::string stem =
sourceCopy.displayName.empty() ? std::string("resample") : sourceCopy.displayName;
const BankPaths paths = deriveBankPaths(projectDir, stem, uniqueTag);
prep.destDir = paths.absoluteDir;
prep.destPath = paths.absoluteDir + "/" + paths.fileName;
// Replace keeps the entry's identity and its slot — the sound iterated, it did not
// become a different capture. The superseded FILE is untouched: it stays on disk,
// unreferenced, until a prune reclaims it, which is the iterate loop's recovery floor.
prep.landed.id =
prep.replace ? sourceCopy.id : ("bake-" + uniqueTag + "-" + paths.fileName);
prep.landed.displayName =
prep.replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
prep.landed.relativePath = paths.relativePath; // project-relative (invariant)
prep.landed.channelCount = static_cast<int>(layout.channelCount);
prep.landed.sampleRate = static_cast<int>(layout.sampleRate);
prep.landed.lengthSeconds =
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
static_cast<double>(layout.sampleRate)
: 0.0;
// Rendered AT root — that is what makes it survive.
prep.landed.rootNote = request.rootNote;
prep.landed.tier = model::Tier::Scratch;
prep.landed.contentHash = contentHash;
prep.landed.createdTimestamp = nowSec;
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
// repeated bake readable as one iteration chain.
prep.landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
return prep;
}
// The mutating half. From its first successful write a file exists and the book can change,
// so a throw out of THIS function is not invisible to the project — which is why its caller
// reports it differently from a prepare that threw. Its own refusals withdraw the file they
// wrote, best-effort. Mutates book + ledger without persisting; the caller persists once for
// the batch.
BakeOutcome commitLanding(ReaSamplerSession& session, PreparedLanding& prep) {
std::error_code ec;
fs::create_directories(prep.destDir, ec); // idempotent; the write reports failure
if (!writeFileBytes(prep.destPath, prep.bytes)) {
// A failed write can still have created the file before failing; withdraw it for
// the same reason the refused index below does.
fs::remove(prep.destPath, ec);
return refuse(BakeStatus::Failed, "could not write the bake into the bank folder",
prep.outcome.generation);
}
BankBook& book = session.book();
// On the replace path prepareLanding set `landed.id` to the source's own id, so this is
// the entry being refreshed in place.
const bool indexed =
prep.replace ? book.updateSampleInPlace(prep.landed.id, prep.landed)
: (book.index(prep.bankId) &&
book.index(prep.bankId)->add(prep.landed) == AddResult::Added);
if (!indexed) {
// Self-cleanup of a file this call wrote seconds ago and never indexed — the
// carve-out shell/persist/CLAUDE.md states, not prune's authority over the bank's
// known bytes. Leaving it would deposit an untracked orphan per refused bake.
fs::remove(prep.destPath, ec);
return refuse(BakeStatus::IndexRejected,
prep.replace ? "the bank refused the replacement"
: "the bank refused the new capture",
prep.outcome.generation);
}
session.recordCreated(prep.landed, tracking::OriginKind::Capture);
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = prep.landed.id;
out.relativePath = prep.landed.relativePath;
out.displayName = prep.landed.displayName;
out.message = prep.replace ? "replaced the bank entry" : "added as a distinct capture";
return out;
}
// One request landed, with the two failure truths kept apart: a throw out of the prepare
// half is a refusal that left the project untouched, while a throw out of the commit half
// may have written a file and changed the book — calling that one "refused" would tell the
// user nothing happened when something did.
struct LandingAttempt {
BakeOutcome outcome;
bool partial = false; // the commit threw: a file and/or an entry may exist
bool changedBook = false; // the book gained or refreshed an entry, unpersisted so far
};
LandingAttempt attemptLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
LandingAttempt attempt;
PreparedLanding prep;
try {
prep = prepareLanding(session, projectDir, request);
} catch (const std::exception& e) {
attempt.outcome =
refuse(BakeStatus::Failed,
std::string("the landing failed before it wrote anything: ") + e.what(),
request.generation);
return attempt;
} catch (...) {
attempt.outcome =
refuse(BakeStatus::Failed,
"the landing failed before it wrote anything, for an unknown reason",
request.generation);
return attempt;
}
if (prep.settled) {
attempt.outcome = std::move(*prep.settled);
return attempt; // a refusal, or a dedup hit that changed nothing
}
try {
attempt.outcome = commitLanding(session, prep);
attempt.changedBook = attempt.outcome.status == BakeStatus::Ok;
} catch (const std::exception& e) {
attempt.partial = true;
attempt.outcome = refuse(BakeStatus::Failed,
std::string("the landing failed while writing: ") + e.what(),
request.generation);
} catch (...) {
attempt.partial = true;
attempt.outcome = refuse(BakeStatus::Failed,
"the landing failed while writing, for an unknown reason",
request.generation);
}
return attempt;
}
// Every "rsbake_*" key in one project, keys only — the value can outgrow // Every "rsbake_*" key in one project, keys only — the value can outgrow
// EnumProjExtState's fixed buffer, so it is read separately by the growing reader. // EnumProjExtState's fixed buffer, so it is read separately by the growing reader.
std::vector<std::string> pendingBakeKeys(ReaProject* proj) { std::vector<std::string> pendingBakeKeys(ReaProject* proj) {
@@ -329,15 +75,6 @@ KeyRead readKey(ReaProject* proj, const std::string& key) {
return out; return out;
} }
// Writes one value under `key` and PROVES it by reading the key back. SetProjExtState's own
// return cannot do that: it is the size of the whole extname's state, which `banks` alone
// keeps non-zero in every case a bake can reach. One extra read per written key, on a cold
// path. An empty `value` is a clear.
bool writeKeyVerified(ReaProject* proj, const std::string& key, const std::string& value) {
SetProjExtState(proj, kProjExtNamespace(), key.c_str(), value.c_str());
return wire::bakeWriteLanded(value, readKey(proj, key).value);
}
struct OpenProject { struct OpenProject {
ReaProject* proj = nullptr; ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project std::string dir; // the project's own directory; empty for a never-saved project
@@ -390,6 +127,50 @@ private:
int flags_ = 0; int flags_ = 0;
}; };
// 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.
wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
try {
std::string value; // empty = clear the key
if (entry.write == ScannedKey::Write::Answer) {
// Where a landing earns the word: the pass's persist runs after the whole scan,
// so until here nothing had observed whether the project took it. Without it
// the entry lives in memory alone, which is a failed bake to anyone who
// reloads — so it is answered as one rather than as an Ok.
const wire::BakeLanding after =
wire::bakeLandingAfterPersist(entry.report.landing, persisted);
if (after == wire::BakeLanding::Unpersisted) {
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",
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
// the instrument's own copy on the wire.
} else {
entry.report.detail = entry.outcome.message;
}
entry.report.landing = after;
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.
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;
} catch (...) {
return wire::BakeWriteProof::Unknown;
}
}
} // namespace } // namespace
void RunResampleBake(ReaSamplerSession& session) { void RunResampleBake(ReaSamplerSession& session) {
@@ -412,7 +193,8 @@ void RunResampleBake(ReaSamplerSession& session) {
std::vector<ScannedKey> scanned; std::vector<ScannedKey> scanned;
wire::BakeScanTally tally; // every verdict below is counted, skips included wire::BakeScanTally tally; // every verdict below is counted, skips included
std::string aborted; // set only when the scan itself threw std::string aborted; // set only when the scan itself threw
bool persisted = false; // the pass's ONE persist actually happened bool persisted = false; // the pass's ONE persist reported success
bool bookChanged = false; // some landing added or refreshed an entry
// The scan allocates outside attemptLanding's own guards too (readKey grows a buffer // 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 // 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 // the answers already buffered — the exact no-answer-with-a-silent-console this action
@@ -465,25 +247,24 @@ void RunResampleBake(ReaSamplerSession& session) {
if (verdict == wire::BakeScanVerdict::RefuseWrongProject) { if (verdict == wire::BakeScanVerdict::RefuseWrongProject) {
entry.outcome = entry.outcome =
refuse(BakeStatus::WrongProject, refuseBake(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension has " "this bake's project tab is not the one the extension "
"loaded -- focus that tab and try again", "has loaded -- focus that tab and try again",
request->generation); request->generation);
} else { } else {
LandingAttempt attempt = attemptLanding(session, open.dir, *request); LandingAttempt attempt = attemptLanding(session, open.dir, *request);
if (attempt.outcome.status == BakeStatus::Ok) ++tally.landed; if (attempt.outcome.status == BakeStatus::Ok) ++tally.landed;
// Unpersisted rather than Banked for anything that changed the book: the if (attempt.changedBook) bookChanged = true;
// pass's persist has not run yet, and the write loop is where the // Never Banked here, on ANY path: the pass's persist has not run yet,
// upgrade is earned. A dedup hit changed nothing, so its landing is not // and bakeLandingAfterPersist is the one place that word is earned. A
// this pass's to lose. // dedup hit is no exception — the entry it points at may be one this
// same pass added and is about to fail to persist.
if (attempt.partial) if (attempt.partial)
entry.report.landing = wire::BakeLanding::Partial; entry.report.landing = wire::BakeLanding::Partial;
else if (attempt.outcome.status != BakeStatus::Ok) else if (attempt.outcome.status != BakeStatus::Ok)
entry.report.landing = wire::BakeLanding::Refused; entry.report.landing = wire::BakeLanding::Refused;
else else
entry.report.landing = attempt.changedBook entry.report.landing = wire::BakeLanding::Unpersisted;
? wire::BakeLanding::Unpersisted
: wire::BakeLanding::Banked;
entry.outcome = std::move(attempt.outcome); entry.outcome = std::move(attempt.outcome);
} }
++tally.answered; // QUEUED, not written — the write loop below judges that ++tally.answered; // QUEUED, not written — the write loop below judges that
@@ -493,56 +274,58 @@ void RunResampleBake(ReaSamplerSession& session) {
} }
if (tally.landed > 0) { if (tally.landed > 0) {
// A bake changes what a live instance would play, so the generation bump rides // A dedup hit rides the persist too, because that is the only observation that
// the persist — every other open instance refreshes hands-free. // can promote its landing — but it added nothing, so it earns neither the
session.bumpBankGeneration(); // refresh every open instance pays for nor an undo point named after a bake
// that banked nothing.
if (bookChanged) session.bumpBankGeneration();
persisted = session.saveToActiveProject(); persisted = session.saveToActiveProject();
if (persisted) if (persisted && bookChanged)
undo.recordPoint("ReaSampler: resample bake into bank", UNDO_STATE_MISCCFG); undo.recordPoint("ReaSampler: resample bake into bank", UNDO_STATE_MISCCFG);
} }
} catch (...) { } catch (...) {
aborted = aborted =
"ReaSampler resample: the landing pass stopped early on an internal failure. " "ReaSampler resample: the landing pass stopped early on an internal failure. "
"The answers it had already prepared are written below; any key whose answer it " "The answers it had already prepared are reported below; any key whose answer "
"had not prepared yet went unanswered and is not named at all.\n"; "it had not prepared yet went unanswered and is not named at all.\n";
} }
// OUTSIDE the undo block on purpose: the answer is transient handshake state, and an // OUTSIDE the undo block on purpose: the answer is transient handshake state, and an
// undo point that captured it could restore the consumed request on the next Ctrl-Z — // undo point that captured it could restore the consumed request on the next Ctrl-Z —
// which the following bake would then re-land against a temp file that is long gone. // which the following bake would then re-land against a temp file that is long gone.
std::string console = aborted; std::string console = aborted;
try {
for (ScannedKey& entry : scanned) { for (ScannedKey& entry : scanned) {
if (entry.write != ScannedKey::Write::None) { if (entry.write != ScannedKey::Write::None) {
std::string value; // empty = clear the key entry.report.proof = writeBackOne(entry, persisted);
if (entry.write == ScannedKey::Write::Answer) { if (entry.write == ScannedKey::Write::Answer) {
// Where a landing earns the word: the persist ran after the whole scan, so if (entry.report.proof == wire::BakeWriteProof::Rejected)
// until here nothing had observed whether the project took it. Without it
// the entry lives in memory alone, which is a failed bake to anyone who
// reloads — so it is answered as one rather than as an Ok.
if (entry.report.landing == wire::BakeLanding::Unpersisted) {
if (persisted)
entry.report.landing = wire::BakeLanding::Banked;
else
entry.outcome = refuse(BakeStatus::Failed,
"the bake reached the bank in memory, but "
"this pass could not persist it, so the "
"project's saved bank state does not carry it",
entry.outcome.generation);
}
value = wire::encodeBakeOutcome(entry.outcome);
entry.report.detail = entry.outcome.message;
}
entry.report.writeConfirmed = writeKeyVerified(entry.proj, entry.key, value);
if (!entry.report.writeConfirmed && entry.write == ScannedKey::Write::Answer)
++tally.writeFailed; ++tally.writeFailed;
else if (entry.report.proof == wire::BakeWriteProof::Unknown)
++tally.writeUnproven;
} }
// Unconditional, including on success: the tally counts keys but cannot name them, }
// and the key name is the only thing that tells an instance which line is its own. // Unconditional, including on success: the tally counts keys but cannot name
// them, and the key name is the only thing that tells an instance which line is
// its own.
console += wire::describeBakeKey(entry.key, entry.report); console += wire::describeBakeKey(entry.key, entry.report);
} }
console += wire::describeBakeScan(tally); console += wire::describeBakeScan(tally);
} catch (...) {
// Whatever lines already accumulated must still reach the console — that guarantee
// is this action's whole reason for existing. A second throw while appending the
// note below is swallowed for exactly the same reason: the print is what matters,
// and append gives the strong guarantee, so `console` survives either way.
try {
console +=
"ReaSampler resample: the write-back pass stopped early on an internal "
"failure. The lines above are everything it managed to report; any key not "
"named above may or may not have been answered.\n";
} catch (...) {
}
}
if (!console.empty()) ShowConsoleMsg(console.c_str()); if (!console.empty()) ShowConsoleMsg(console.c_str());
if (tally.landed > 0) bankPanelRefresh(); if (bookChanged) bankPanelRefresh();
} }
} // namespace reasampler::capture } // namespace reasampler::capture
+274
View File
@@ -0,0 +1,274 @@
// bake_landing.cpp — see bake_landing.h. Touches no REAPER API: a landing is filesystem +
// book + ledger only, which is what lets the scan pass own every ext-state read and write.
#include "shell/capture/bake_landing.h"
#include <ctime>
#include <exception>
#include <filesystem>
#include <fstream>
#include <optional>
#include <system_error>
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // deriveBankPaths
#include "core/capture/wav_codec.h" // parseWavLayout / hashWavContent
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/model/resample_name.h" // the iteration-chain display name
#include "core/tracking/tracking_authority.h" // resampleLanding (replace vs add-distinct)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "shell/persist/session.h"
namespace reasampler::capture {
namespace {
namespace fs = std::filesystem;
using model::AddResult;
using model::Sample;
using model::nextIterationName;
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
return f.good();
}
// The bank holding `sampleId`, plus the entry itself. nullptr when no bank holds it.
const Sample* findSourceSample(const BankBook& book, const std::string& sampleId,
std::string& bankIdOut) {
for (const Bank& b : book.banks()) {
if (const Sample* s = b.index.query(sampleId)) {
bankIdOut = b.id;
return s;
}
}
return nullptr;
}
// Everything a landing DECIDES, before it touches disk or book: the staged WAV read and
// hash, the source lookup, replace-vs-add, and the dedup short-circuit. Split from the
// commit below so the two halves' throws can be told apart — a prepare that threw left the
// project untouched, and only a commit that threw may have written something.
struct PreparedLanding {
std::optional<BakeOutcome> settled; // a refusal or a dedup hit: nothing left to commit
BakeOutcome outcome; // the shape a successful commit completes
Sample landed; // the entry the commit indexes
std::vector<std::uint8_t> bytes; // the staged file, already read
std::string bankId;
std::string destDir;
std::string destPath;
bool replace = false;
};
PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
PreparedLanding prep;
if (projectDir.empty()) {
prep.settled = refuseBake(BakeStatus::NoProject,
"no saved project, so the bank has no location",
request.generation);
return prep;
}
prep.bytes = util::readFileBytes(request.stagedFilePath);
if (prep.bytes.empty()) {
prep.settled = refuseBake(BakeStatus::StagedMissing,
"the staged render was unreadable", request.generation);
return prep;
}
const WavLayout layout = parseWavLayout(prep.bytes);
if (!layout.valid || layout.frameCount() == 0) {
prep.settled = refuseBake(BakeStatus::StagedMissing,
"the staged render is not a usable WAV", request.generation);
return prep;
}
BankBook& book = session.book();
const Sample* source = findSourceSample(book, request.sourceSampleId, prep.bankId);
if (!source) {
prep.settled = refuseBake(BakeStatus::NoSource,
"the resampled capture is not in any bank",
request.generation);
return prep;
}
// Copied, not aliased: every mutation the commit makes invalidates the book's pointers.
const Sample sourceCopy = *source;
const tracking::Landing landing = tracking::resampleLanding(
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
prep.replace = (landing == tracking::Landing::Replace);
// WAV-aware hash: the bank's dedup key, and — on the add path only — the reason a
// byte-identical bake yields no second entry. Replace never dedups, matching
// updateSampleInPlace's own contract: an in-place refresh is not an insert.
const std::string contentHash = hashWavContent(prep.bytes);
prep.outcome.generation = request.generation;
prep.outcome.rootNote = request.rootNote;
prep.outcome.channelCount = static_cast<int>(layout.channelCount);
prep.outcome.replaced = prep.replace;
if (!prep.replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(prep.bankId)) {
if (const Sample* existing = index->findByHash(contentHash)) {
// Dedup BEFORE the disk write: this bake is bytes the bank already holds,
// so it re-points at that entry rather than depositing an unreferenced
// twin for the prune to reclaim later. It still leaves `changedBook` false
// and still rides the pass's persist — the entry it points at may be one
// the same pass added moments ago.
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = existing->id;
out.relativePath = existing->relativePath;
out.displayName = existing->displayName;
out.message = "identical to an existing capture -- pointed at it";
prep.settled = std::move(out);
return prep;
}
}
}
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
// Seconds alone are not unique enough here: two bakes of the same source inside one
// second would derive the same file name, and the second would overwrite a file the
// first had just indexed. The content hash separates them, and leaves a re-bake of
// identical bytes idempotent rather than duplicated.
const std::string uniqueTag =
std::to_string(nowSec) +
(contentHash.empty() ? std::string{} : "-" + contentHash.substr(0, 8));
const std::string stem =
sourceCopy.displayName.empty() ? std::string("resample") : sourceCopy.displayName;
const BankPaths paths = deriveBankPaths(projectDir, stem, uniqueTag);
prep.destDir = paths.absoluteDir;
prep.destPath = paths.absoluteDir + "/" + paths.fileName;
// Replace keeps the entry's identity and its slot — the sound iterated, it did not
// become a different capture. The superseded FILE is untouched: it stays on disk,
// unreferenced, until a prune reclaims it, which is the iterate loop's recovery floor.
prep.landed.id =
prep.replace ? sourceCopy.id : ("bake-" + uniqueTag + "-" + paths.fileName);
prep.landed.displayName =
prep.replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
prep.landed.relativePath = paths.relativePath; // project-relative (invariant)
prep.landed.channelCount = static_cast<int>(layout.channelCount);
prep.landed.sampleRate = static_cast<int>(layout.sampleRate);
prep.landed.lengthSeconds =
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
static_cast<double>(layout.sampleRate)
: 0.0;
// Rendered AT root — that is what makes it survive.
prep.landed.rootNote = request.rootNote;
prep.landed.tier = model::Tier::Scratch;
prep.landed.contentHash = contentHash;
prep.landed.createdTimestamp = nowSec;
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
// repeated bake readable as one iteration chain.
prep.landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
return prep;
}
// The mutating half. From its first successful write a file exists and the book can change,
// so a throw out of THIS function is not invisible to the project — which is why its caller
// reports it differently from a prepare that threw. Its own refusals withdraw the file they
// wrote, best-effort.
BakeOutcome commitLanding(ReaSamplerSession& session, PreparedLanding& prep) {
std::error_code ec;
fs::create_directories(prep.destDir, ec); // idempotent; the write reports failure
if (!writeFileBytes(prep.destPath, prep.bytes)) {
// A failed write can still have created the file before failing; withdraw it for
// the same reason the refused index below does.
fs::remove(prep.destPath, ec);
return refuseBake(BakeStatus::Failed,
"could not write the bake into the bank folder",
prep.outcome.generation);
}
BankBook& book = session.book();
// On the replace path prepareLanding set `landed.id` to the source's own id, so this is
// the entry being refreshed in place.
const bool indexed =
prep.replace ? book.updateSampleInPlace(prep.landed.id, prep.landed)
: (book.index(prep.bankId) &&
book.index(prep.bankId)->add(prep.landed) == AddResult::Added);
if (!indexed) {
// Self-cleanup of a file this call wrote seconds ago and never indexed — the
// carve-out shell/persist/CLAUDE.md states, not prune's authority over the bank's
// known bytes. Leaving it would deposit an untracked orphan per refused bake.
fs::remove(prep.destPath, ec);
return refuseBake(BakeStatus::IndexRejected,
prep.replace ? "the bank refused the replacement"
: "the bank refused the new capture",
prep.outcome.generation);
}
session.recordCreated(prep.landed, tracking::OriginKind::Capture);
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = prep.landed.id;
out.relativePath = prep.landed.relativePath;
out.displayName = prep.landed.displayName;
out.message = prep.replace ? "replaced the bank entry" : "added as a distinct capture";
return out;
}
} // namespace
BakeOutcome refuseBake(BakeStatus status, std::string message, std::int64_t generation) {
BakeOutcome out;
out.status = status;
out.message = std::move(message);
out.generation = generation;
return out;
}
LandingAttempt attemptLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
LandingAttempt attempt;
PreparedLanding prep;
try {
prep = prepareLanding(session, projectDir, request);
} catch (const std::exception& e) {
attempt.outcome = refuseBake(
BakeStatus::Failed,
std::string("the landing failed before it wrote anything: ") + e.what(),
request.generation);
return attempt;
} catch (...) {
attempt.outcome = refuseBake(
BakeStatus::Failed,
"the landing failed before it wrote anything, for an unknown reason",
request.generation);
return attempt;
}
if (prep.settled) {
attempt.outcome = std::move(*prep.settled);
return attempt; // a refusal, or a dedup hit that changed nothing
}
try {
attempt.outcome = commitLanding(session, prep);
attempt.changedBook = attempt.outcome.status == BakeStatus::Ok;
} catch (const std::exception& e) {
attempt.partial = true;
attempt.outcome =
refuseBake(BakeStatus::Failed,
std::string("the landing failed while writing: ") + e.what(),
request.generation);
} catch (...) {
attempt.partial = true;
attempt.outcome = refuseBake(BakeStatus::Failed,
"the landing failed while writing, for an unknown reason",
request.generation);
}
return attempt;
}
} // namespace reasampler::capture
+40
View File
@@ -0,0 +1,40 @@
#pragma once
// bake_landing — landing ONE bake request into the loaded project's bank: read and hash the
// staged WAV, resolve replace-vs-add, write the file, index it, seed its lineage. The scan
// pass that finds requests across the open tabs and answers them is `bake_land`; this is
// what it calls per request, and it neither reads nor writes an ext-state key.
#include <cstdint>
#include <string>
#include "core/wire/bake_wire.h"
namespace reasampler {
class ReaSamplerSession;
}
namespace reasampler::capture {
// A refusal outcome shaped for the key it will be written back to. Shared with the scan
// pass, which refuses on grounds no single landing can see — a request from another tab, or
// a landing the pass then failed to persist.
wire::BakeOutcome refuseBake(wire::BakeStatus status, std::string message,
std::int64_t generation);
// One request landed, with the two failure truths kept apart: a throw out of the prepare
// half is a refusal that left the project untouched, while a throw out of the commit half
// may have written a file and changed the book — calling that one "refused" would tell the
// user nothing happened when something did.
struct LandingAttempt {
wire::BakeOutcome outcome;
bool partial = false; // the commit threw: a file and/or an entry may exist
bool changedBook = false; // the book gained or refreshed an entry, unpersisted so far
};
// Never throws: both halves run under their own catch, and which half threw is what
// `partial` carries. Mutates book + ledger WITHOUT persisting — the caller persists once
// for its whole batch, which is why no landing may report itself as banked.
LandingAttempt attemptLanding(ReaSamplerSession& session, const std::string& projectDir,
const wire::BakeRequest& request);
} // namespace reasampler::capture
+1 -1
View File
@@ -108,7 +108,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
- `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_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). - `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).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius. - `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
- `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. The three sentences that are ABOUT the key name it, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry. - `instrument_bake` — the instrument's half of the resample chain, on the UI thread: render the dialed sound through the pure `core/instrument/bake` modules at the instance's PERSISTED PREVIEW VELOCITY (the velocity the user has been auditioning at — three velocity curves are live, so it is a property of the sound and not a render detail), stage the WAV OUTSIDE the bank folder, publish one `rsbake_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. What that key holds afterwards is classified by `core/wire`'s pure `classifyBakeAnswer`, and each of its five non-answers gets its OWN sentence — a silent no-answer stays a failure, but the user is told whether nothing wrote over the key, a stale generation was answered, the answer came in a wire this build cannot read, the request was cleared, or it was refused. All five name the key, because the extension prints one console line per key it scanned and the key is what correlates the two in a multi-instance session. None of them claims the landing never ran — nothing on this side can observe that. Two stack-RAII guards mirror `FxBypassGuard`'s discipline: the staged file and the request key are both cleared on every exit path, so a failed bake leaves no temp, no bank entry and no parameter reset. `bakeAvailable` is the affordance's paint gate. A cloned `instanceGuid` (two instances sharing one `rsbake_` key) is NOT handled here — the residual is contained by pre-existing tracking machinery instead: `planUsagePublish`'s sticky `unioned` poison plus `tiedUsageExists` (`core/tracking/tracking_authority.cpp`) force a clone's bake to `AddDistinct` rather than silently replacing a sibling's entry.
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs. - `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here. - `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / title band), label helpers, and the velocity-curve box derivation — the helpers more than one band TU needs. The deck's control ids, group ids and group composition are the pure `deck_groups` module's, not this file's. The piano-strip and root-key draws live in `editor_paint_chrome`, their only consumer, not here.
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)* - `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
+139 -15
View File
@@ -7,8 +7,9 @@
// break a delimiter-based format; the refusals every house wire record shares (wrong tag, // break a delimiter-based format; the refusals every house wire record shares (wrong tag,
// truncation, trailing garbage, a swapped record kind); an unrecognized status integer // truncation, trailing garbage, a swapped record kind); an unrecognized status integer
// degrading to Failed rather than to Ok; the action lookup name's leading underscore and // 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; and the // 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. // 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.
#include "../src/core/wire/bake_wire.h" #include "../src/core/wire/bake_wire.h"
@@ -26,6 +27,13 @@ static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \ #define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// The one answer bake_land emits for a landing its pass could not persist. Pinned once
// 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";
int main() { int main() {
// --- The exact bytes on the wire ------------------------------------------------- // --- The exact bytes on the wire -------------------------------------------------
// A round-trip alone would pass a reordered or inserted field; the tags exist to guard // A round-trip alone would pass a reordered or inserted field; the tags exist to guard
@@ -442,6 +450,24 @@ int main() {
CHECK(describeBakeScan(rejectedTwo).find("2 answers could not be written back") != CHECK(describeBakeScan(rejectedTwo).find("2 answers could not be written back") !=
std::string::npos); std::string::npos);
// An answer whose write-and-check THREW is neither landed nor known-failed, and it
// must break the silence too: the whole point of the gate is that no fault in the
// pass leaves the asking instance with a no-answer and an empty console.
BakeScanTally unproven = clean;
unproven.writeUnproven = 1;
const std::string unsure = describeBakeScan(unproven);
CHECK(!unsure.empty());
CHECK(unsure.find("1 answer could not be checked after writing") !=
std::string::npos);
CHECK(unsure.find("is unknown") != std::string::npos);
// ...and it is NOT reported as a rejection, which is a different claim.
CHECK(unsure.find("could not be written back") == std::string::npos);
BakeScanTally unprovenTwo = clean;
unprovenTwo.answered = 2;
unprovenTwo.writeUnproven = 2;
CHECK(describeBakeScan(unprovenTwo).find("2 answers could not be checked") !=
std::string::npos);
// Nothing found at all: the observation is stated and the candidates listed, with // Nothing found at all: the observation is stated and the candidates listed, with
// no winner picked — the enumeration's visibility of a key set but not yet saved is // no winner picked — the enumeration's visibility of a key set but not yet saved is
// undocumented, so "not the same project's ext state" may not name it. // undocumented, so "not the same project's ext state" may not name it.
@@ -521,7 +547,7 @@ int main() {
BakeKeyOutcome landed; BakeKeyOutcome landed;
landed.verdict = BakeScanVerdict::Land; landed.verdict = BakeScanVerdict::Land;
landed.landing = BakeLanding::Banked; landed.landing = BakeLanding::Banked;
landed.writeConfirmed = true; landed.proof = BakeWriteProof::Confirmed;
landed.detail = "added as a distinct capture"; landed.detail = "added as a distinct capture";
const std::string ok = describeBakeKey(key, landed); const std::string ok = describeBakeKey(key, landed);
CHECK(ok.find(key) != std::string::npos); CHECK(ok.find(key) != std::string::npos);
@@ -531,16 +557,31 @@ int main() {
// The outcome's own reason rides the SAME line: one self-contained line per key, // 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. // 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); 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);
// A landing whose answer write was REJECTED — the state the instrument reads as // A landing whose answer write was REJECTED — the state the instrument reads as
// Unanswered. This line is the only place it is ever named. // Unanswered. This line is the only place it is ever named.
BakeKeyOutcome lost = landed; BakeKeyOutcome lost = landed;
lost.writeConfirmed = false; lost.proof = BakeWriteProof::Rejected;
const std::string dropped = describeBakeKey(key, lost); const std::string dropped = describeBakeKey(key, lost);
CHECK(dropped.find("landed into the bank") != std::string::npos); CHECK(dropped.find("landed into the bank") != std::string::npos);
CHECK(dropped.find("could NOT be written back") != std::string::npos); CHECK(dropped.find("could NOT be written back") != std::string::npos);
CHECK(dropped.find("will report no answer") != std::string::npos); CHECK(dropped.find("will report no answer") != std::string::npos);
// The write-and-check itself failing is a THIRD state, not a rejection: claiming
// the answer did not land would be a fact this pass never observed.
BakeKeyOutcome unchecked = landed;
unchecked.proof = BakeWriteProof::Unknown;
const std::string unsure = describeBakeKey(key, unchecked);
CHECK(unsure.find("is unknown") != std::string::npos);
CHECK(unsure.find("could NOT be written back") == std::string::npos);
CHECK(unsure.find("The answer was written back") == std::string::npos);
CHECK(unsure != ok && unsure != dropped);
// The four landing states are four different lines: a bake the project never // The four landing states are four different lines: a bake the project never
// persisted, and one that threw mid-write, may not read as a clean success or as a // persisted, and one that threw mid-write, may not read as a clean success or as a
// clean refusal. // clean refusal.
@@ -548,8 +589,12 @@ int main() {
unpersisted.landing = BakeLanding::Unpersisted; unpersisted.landing = BakeLanding::Unpersisted;
const std::string memoryOnly = describeBakeKey(key, unpersisted); const std::string memoryOnly = describeBakeKey(key, unpersisted);
CHECK(memoryOnly.find("IN MEMORY ONLY") != std::string::npos); CHECK(memoryOnly.find("IN MEMORY ONLY") != std::string::npos);
CHECK(memoryOnly.find("could not persist it") != std::string::npos); CHECK(memoryOnly.find("persist did not report success") != std::string::npos);
CHECK(memoryOnly != ok); 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
// been in the project since long before this pass.
CHECK(memoryOnly.find("does not carry it") == std::string::npos);
BakeKeyOutcome partial = landed; BakeKeyOutcome partial = landed;
partial.landing = BakeLanding::Partial; partial.landing = BakeLanding::Partial;
@@ -562,7 +607,7 @@ int main() {
BakeKeyOutcome bankRefused; BakeKeyOutcome bankRefused;
bankRefused.verdict = BakeScanVerdict::Land; bankRefused.verdict = BakeScanVerdict::Land;
bankRefused.landing = BakeLanding::Refused; bankRefused.landing = BakeLanding::Refused;
bankRefused.writeConfirmed = true; bankRefused.proof = BakeWriteProof::Confirmed;
bankRefused.detail = "the bank refused the new capture"; bankRefused.detail = "the bank refused the new capture";
const std::string refusedLine = describeBakeKey(key, bankRefused); const std::string refusedLine = describeBakeKey(key, bankRefused);
CHECK(refusedLine.find("the landing was refused") != std::string::npos); CHECK(refusedLine.find("the landing was refused") != std::string::npos);
@@ -571,7 +616,7 @@ int main() {
BakeKeyOutcome wrongTab; BakeKeyOutcome wrongTab;
wrongTab.verdict = BakeScanVerdict::RefuseWrongProject; wrongTab.verdict = BakeScanVerdict::RefuseWrongProject;
wrongTab.writeConfirmed = true; wrongTab.proof = BakeWriteProof::Confirmed;
wrongTab.detail = wrongTab.detail =
"this bake's project tab is not the one the extension has loaded -- focus that " "this bake's project tab is not the one the extension has loaded -- focus that "
"tab and try again"; "tab and try again";
@@ -583,14 +628,21 @@ int main() {
// A clear is a write too: it may be claimed only where it was read back. // A clear is a write too: it may be claimed only where it was read back.
BakeKeyOutcome cleared; BakeKeyOutcome cleared;
cleared.verdict = BakeScanVerdict::ClearStale; cleared.verdict = BakeScanVerdict::ClearStale;
cleared.writeConfirmed = true; cleared.proof = BakeWriteProof::Confirmed;
CHECK(describeBakeKey(key, cleared).find("cleared unanswered") != std::string::npos); CHECK(describeBakeKey(key, cleared).find("cleared unanswered") != std::string::npos);
BakeKeyOutcome clearLost = cleared; BakeKeyOutcome clearLost = cleared;
clearLost.writeConfirmed = false; clearLost.proof = BakeWriteProof::Rejected;
const std::string stuck = describeBakeKey(key, clearLost); 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 could NOT be read back") != std::string::npos);
CHECK(stuck.find("next pass will see it again") != std::string::npos); CHECK(stuck.find("next pass will see it again") != std::string::npos);
CHECK(stuck.find("it was cleared") == std::string::npos); CHECK(stuck.find("it was cleared") == 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;
const std::string maybeCleared = describeBakeKey(key, clearUnchecked);
CHECK(maybeCleared.find("whether the clear took is unknown") != std::string::npos);
CHECK(maybeCleared.find("it was cleared unanswered") == std::string::npos);
CHECK(maybeCleared.find("could NOT be read back") == std::string::npos);
BakeKeyOutcome notRequest; BakeKeyOutcome notRequest;
notRequest.verdict = BakeScanVerdict::IgnoreNotARequest; notRequest.verdict = BakeScanVerdict::IgnoreNotARequest;
@@ -613,6 +665,77 @@ int main() {
CHECK(describeBakeKey("rsbake_ffff0000", landed).find("rsbake_ffff0000") != CHECK(describeBakeKey("rsbake_ffff0000", landed).find("rsbake_ffff0000") !=
std::string::npos); std::string::npos);
CHECK(describeBakeKey("rsbake_ffff0000", landed) != ok); CHECK(describeBakeKey("rsbake_ffff0000", landed) != ok);
// A LANDING this build has no word for must not be reported as a VERDICT gap: the
// two are different enums and send a reader to two different places.
BakeKeyOutcome futureLanding = landed;
futureLanding.landing = static_cast<BakeLanding>(99);
const std::string unnamed = describeBakeKey(key, futureLanding);
CHECK(unnamed.find("a landing state this build has no word for") !=
std::string::npos);
CHECK(unnamed.find("a verdict this build has no word for") == std::string::npos);
// A VERDICT gap still reports as one.
BakeKeyOutcome futureVerdict;
futureVerdict.verdict = static_cast<BakeScanVerdict>(99);
CHECK(describeBakeKey(key, futureVerdict).find(
"a verdict this build has no word for") != std::string::npos);
}
// --- The persist/upgrade state machine: the ONE route to Banked -----------------------
// The shell assigns every Land verdict's landing without knowing whether the pass's
// persist ran, then threads the flag through here. The bug this pins: a dedup hit used
// to be answered Banked directly, on the grounds that it changed nothing — false
// exactly when the entry it deduped against was one the SAME pass had just added and
// then failed to persist, which answers Ok for an entry the project does not carry.
{
// A dedup hit and a fresh add are INDISTINGUISHABLE here, by construction: the shell
// assigns Unpersisted to both, so both need the same observation to be promoted.
CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, true) == BakeLanding::Banked);
CHECK(bakeLandingAfterPersist(BakeLanding::Unpersisted, false) ==
BakeLanding::Unpersisted);
// Exhaustive over both arguments: (Unpersisted, persisted) is the ONLY pair that
// produces Banked from anything else, and nothing else is altered at all — so a
// persist that ran cannot launder a refusal or a half-written landing into a
// success, and a persist that did not cannot demote one.
for (const BakeLanding from : {BakeLanding::Refused, BakeLanding::Partial,
BakeLanding::Unpersisted, BakeLanding::Banked}) {
for (const bool persisted : {false, true}) {
const BakeLanding to = bakeLandingAfterPersist(from, persisted);
const bool promotes = from == BakeLanding::Unpersisted && persisted;
CHECK(to == (promotes ? BakeLanding::Banked : from));
CHECK((to == BakeLanding::Banked) ==
(promotes || from == BakeLanding::Banked));
}
}
// A dedup hit driven end to end at both persist outcomes: what the instrument is
// told and what the console prints both follow the flag, and they never disagree.
for (const bool persisted : {true, false}) {
BakeKeyOutcome deduped;
deduped.verdict = BakeScanVerdict::Land;
deduped.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, persisted);
// A landing the pass could not persist is re-encoded as a FAILURE, not the Ok
// it was headed for, so no instance adopts an entry a reload may not find.
BakeOutcome answer;
answer.status = BakeStatus::Ok;
answer.sampleId = "bake-1";
answer.generation = 1893456000;
if (deduped.landing == BakeLanding::Unpersisted) {
answer.status = BakeStatus::Failed;
answer.message = kUnpersistedAnswer;
}
const auto back = decodeBakeOutcome(encodeBakeOutcome(answer));
CHECK(back.has_value());
CHECK(back.has_value() &&
back->status == (persisted ? BakeStatus::Ok : BakeStatus::Failed));
// The console line and the wire answer never disagree about the same key.
deduped.proof = BakeWriteProof::Confirmed;
const std::string line = describeBakeKey("rsbake_0123abcd", deduped);
CHECK((line.find("IN MEMORY ONLY") != std::string::npos) == !persisted);
}
} }
// --- A rejected write is REACHABLE, and it is what the tally and the line come from --- // --- A rejected write is REACHABLE, and it is what the tally and the line come from ---
@@ -670,10 +793,13 @@ int main() {
tally.landed = 1; tally.landed = 1;
BakeKeyOutcome report; BakeKeyOutcome report;
report.verdict = BakeScanVerdict::Land; report.verdict = BakeScanVerdict::Land;
report.landing = BakeLanding::Banked; // The shell reaches Banked only through the upgrade — never by assignment.
report.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, true);
report.detail = landedOk.message; report.detail = landedOk.message;
report.writeConfirmed = bakeWriteLanded(answer, rejecting.read(key)); report.proof = bakeWriteLanded(answer, rejecting.read(key))
if (!report.writeConfirmed) ++tally.writeFailed; ? BakeWriteProof::Confirmed
: BakeWriteProof::Rejected;
if (report.proof == BakeWriteProof::Rejected) ++tally.writeFailed;
CHECK(tally.writeFailed == 1); CHECK(tally.writeFailed == 1);
CHECK(describeBakeKey(key, report).find("could NOT be written back") != CHECK(describeBakeKey(key, report).find("could NOT be written back") !=
std::string::npos); std::string::npos);
@@ -769,9 +895,7 @@ int main() {
// adopted it would be pointing at something no reload will have. // adopted it would be pointing at something no reload will have.
BakeOutcome unpersisted = noProject; BakeOutcome unpersisted = noProject;
unpersisted.status = BakeStatus::Failed; unpersisted.status = BakeStatus::Failed;
unpersisted.message = unpersisted.message = kUnpersistedAnswer;
"the bake reached the bank in memory, but this pass could not persist it, so "
"the project's saved bank state does not carry it";
BakeOutcome partial = noProject; BakeOutcome partial = noProject;
partial.status = BakeStatus::Failed; partial.status = BakeStatus::Failed;