Merge bake diagnosis soundness: every printed claim backed by an observation, write proof only where evidence crosses the plugin boundary

This commit is contained in:
2026-08-02 13:45:38 -04:00
19 changed files with 1416 additions and 367 deletions
+90 -11
View File
@@ -654,17 +654,16 @@ tracks-and-range-only shape.
**Context (surfaced by Ψ-W2-T2, mono-collapse).** The collapse (`collapseCapturedFileToMono` **Context (surfaced by Ψ-W2-T2, mono-collapse).** The collapse (`collapseCapturedFileToMono`
/ `core/capture/wav_codec::collapseToMono`) ships for every extension capture path — / `core/capture/wav_codec::collapseToMono`) ships for every extension capture path —
offline, realtime, batch, recapture — but not for `bake_land.cpp`'s `landOne`, the offline, realtime, batch, recapture — but not for `bake_land.cpp`'s landing, the
resample bake's landing function. A dead-center instrument render (the common case resample bake's `prepareLanding` / `commitLanding` pair. A dead-center instrument render (the common case
that motivated Ψ.6 in the first place) is exactly the dual-mono shape the predicate that motivated Ψ.6 in the first place) is exactly the dual-mono shape the predicate
collapses, so an un-collapsed bake keeps paying for the second channel it doesn't need. collapses, so an un-collapsed bake keeps paying for the second channel it doesn't need.
**Not deferred for the reason once given.** `landOne` reads the staged file into `bytes` **Not deferred for the reason once given.** `prepareLanding` reads the staged file into
once (`bake_land.cpp:101`), parses its layout (`:105`), hashes it (`:126`), derives the `prep.bytes` once, parses its layout, hashes it and derives the channel count from that
channel count twice (`:131`, `:178`), and writes it (`:165`) — all from that same one same one buffer, and `commitLanding` writes that buffer — so collapsing it right after the
buffer, so collapsing `bytes` right after the layout parse would keep the hash, the layout parse would keep the hash, the channel count, and the written file consistent by
channel count, and the written file consistent by construction; there is no ordering construction; there is no ordering hazard here to defer around.
hazard here to defer around.
**The real reason.** `bake_land.cpp` is Phase Ξ's freshly-landed surface **The real reason.** `bake_land.cpp` is Phase Ξ's freshly-landed surface
(Ξ-W2-T1, the resample bake chain) and another team is actively remediating it. Landing (Ξ-W2-T1, the resample bake chain) and another team is actively remediating it. Landing
@@ -677,9 +676,9 @@ however many channels the dialed sound has, and `bake_render.cpp:38` reads
`sample.channelCount()` off that render rather than hardcoding 2 — a mono-programmed `sample.channelCount()` off that render rather than hardcoding 2 — a mono-programmed
sound already bakes to a mono file today, with no change needed. sound already bakes to a mono file today, with no change needed.
**Intended fix.** Once `bake_land.cpp` is quiet, call `collapseToMono` on the staged **Intended fix.** Once `bake_land.cpp` is quiet, call `collapseToMono` on `prep.bytes` in
`bytes` in `landOne` right after the layout parse (`:105`) and before the hash (`:126`), `prepareLanding` right after the layout parse and before the hash, matching the
matching the offline/realtime insertion point (post-parse, pre-identity-read). offline/realtime insertion point (post-parse, pre-identity-read).
**Priority / risk.** Low — a size optimization on an already-correct path, not a **Priority / risk.** Low — a size optimization on an already-correct path, not a
precision-invariant gap; the bake's dual-mono case still lands as a valid (if larger) precision-invariant gap; the bake's dual-mono case still lands as a valid (if larger)
@@ -768,3 +767,83 @@ above.
function under a `<module>_tests` target with no REAPER, no VST3 SDK, and no function under a `<module>_tests` target with no REAPER, no VST3 SDK, and no
filesystem includes; `shell/capture/render_bounds_gate` shrinks to the file-move and filesystem includes; `shell/capture/render_bounds_gate` shrinks to the file-move and
the two callers' plumbing. the two callers' plumbing.
## The capture path ignores `saveToActiveProject`'s return at four sites
**Context.** `saveToActiveProject()` returns false for exactly two reasons — no active
project, or an unsaved one — and in both cases NOTHING was written. Four capture sites
discard that return outright: `capture_orchestrator.cpp:343`, `capture_batch.cpp:266` and
`:333`, and `realtime_lifecycle.cpp:39`.
**The wart.** A capture on an unsaved project renders the file into the bank folder, adds
the `Sample` to the in-memory book, records a birth record in memory — and loses all three
on reload. The bytes stay on disk with no index entry and no persisted ledger record, so
they are a foreign file prune will never reclaim (an unrecorded file is untouchable by
design — `core/tracking/CLAUDE.md`). Nothing is printed. The bank-op family already reads
this return and discards its undo point on a false; the capture family does not read it at
all.
**Why filed, not fixed.** Pre-existing, and the right answer is a product decision this
dispatch had no mandate for: refuse the capture up front, keep it and warn, or prompt for
a Save-As (the bank ops chose "quiet persist by design, deliberately no Save-As prompt" —
whether capture should follow is a separate call).
**Done looks like.** A capture attempted with no saved project either does not write bytes
at all, or writes them and says so in a sentence naming what will not survive a reload —
and the choice between those two is recorded rather than implicit.
## `panel_input`'s wheel handler persists the whole book per wheel message
**Context.** `panel_input.cpp:450``handleWheel` calls `markTailDirty()` on every wheel
message that actually moves `manualMs`, while the pointer is over the footer in Manual
mode. (It coalesces sub-notch deltas within ONE message and no-ops at a bound, so the
count is wheel messages that changed the value, not raw notches.)
**The wart.** `markTailDirty` is `saveToActiveProject()` — a full `BankBook` serialize plus
six ext-state value writes on the UI thread — for a setting that is one number. A flick
over the footer is a dozen of them in a few hundred milliseconds. Disproportionate rather
than incorrect: no guardrail is violated (this is nowhere near the two named hot paths),
and the writes are idempotent.
**Intended fix.** Coalesce: mark dirty and let one timer tick flush, the same shape the
panel already uses elsewhere for repaint batching.
**Done looks like.** A continuous wheel gesture over the footer produces one persist, and
the value that lands is the gesture's final one.
## `RunCaptureItemAssign`'s undo point does not follow the pattern its comment claims
**Context.** `capture_orchestrator.cpp:364-365` states that the action follows the bank-op
family's discard-on-unsaved pattern.
**The wart.** It does not: `:382-383` records the undo point unconditionally whenever
`sampleId` is non-empty, and never consults the persist's return at all. So on an unsaved
project it records an undo point for ext-state that was never written — the empty
no-effect entry `persistBankOp`'s guardrail exists to avoid. The comment describes the
intended behavior, not the code.
**Why filed, not fixed.** It is one instance of the capture-family gap filed above, and
fixing it alone would leave the other four sites divergent. Fix them together, or explain
in one place why capture differs from bank ops.
**Done looks like.** The comment and the code agree, and the whole capture family answers
the unsaved-project case one way.
## `core/tracking/CLAUDE.md`'s untracked-file enumeration says "reaches the `.rpp`" too loosely
**Context.** `src/core/tracking/CLAUDE.md:24-31` enumerates how a created file can stay
untracked, and describes the ledger as reaching the `.rpp` at the following
`saveToActiveProject()`.
**The wart.** `saveToActiveProject()` writes REAPER's IN-MEMORY project state and marks the
project dirty; REAPER writes the `.rpp` on the project's own save, which may be much later
or never. The sentence was already loose before this branch and is not made wrong by it —
but it is the same over-claim ("a write reached the file on disk") the bake's reporting
pass spent several rounds removing from its own sentences, so it should read the same way.
**Why filed, not fixed.** Editing another layer's own CLAUDE.md from a persist-and-report
dispatch is exactly the boundary crossing the per-directory docs exist to prevent. It is a
doc-keeper edit.
**Done looks like.** The enumeration distinguishes "in the project's state" from "on disk
in the `.rpp`", and does not gain a second home for the distinction.
+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
+2 -2
View File
@@ -77,10 +77,10 @@ This directory owns two cross-artifact contracts specifically:
## Modules ## Modules
- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory. - `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`, and its peer `extStateWriteLanded` — the ONLY verdict on whether a `SetProjExtState` write took, because that API's return is the size of the WHOLE extname's state and cannot speak for one key. Two shells bind it, BOTH on the instrument's per-instance keys: the bake landing's answer/clear write-back and the instrument bridge's prefix-guarded writes. Deliberately NOT the persist, whose return is control flow over undo points — this verdict is a proof value reported to the user, and an observational false there would discard the Ctrl-Z for a bank mutation that landed. Its `nullopt` means ABSENT specifically, so every caller must route an Overflow read to its own "could not check" answer rather than folding it in. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory.
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge. - `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, is what separates an extension that never ran the landing from one that refused) 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` are that same reading counted and spoken — the rationale lives at the type. The report is empty whenever a pass answered anybody, which makes its ABSENCE from the console evidence too: no line means the landing action never ran. - `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`. It owns BOTH ends' reading of that key, since the key's contents are the only evidence either side gets: `classifyBakeAnswer` (instrument side — six kinds, of which `Unanswered`, the request still sitting there untouched, separates "nothing wrote an outcome over our key" from a refusal — it does NOT identify a landing that never ran, since a skipped key and a rejected answer-write look identical from here) and `classifyBakeScan` + `kMaxRequestAgeSeconds` (extension side — the per-key Land / RefuseWrongProject / ClearStale / IgnoreUnreadable / IgnoreNotARequest verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable). `BakeScanTally` + `describeBakeScan` + `describeBakeKey` are that same reading counted and spoken — the rationale lives at the types. **The report's absence is NOT evidence the landing never ran**, and no sentence either artifact prints may say it is: `answered` is pass-wide and counts a QUEUED write, so a pass can answer some other key while skipping ours, or have our own answer's `SetProjExtState` rejected. The summary is therefore silent only when the pass answered somebody, left no key unanswered, and every answer was READ BACK from its own key; `describeBakeKey` prints one line per enumerated key regardless, which is the only thing that names WHICH key — the counts cannot. The read-back verdict behind `BakeWriteProof` is `ext_state_read.h`'s `extStateWriteLanded`, above. `bakeLandingAfterPersist` is the ONE route to a `Banked` landing: every `Land` verdict is assigned `Unpersisted` and passed through it, so no shell path — a dedup hit least of all, since its target may be an entry the same pass just added — can claim the word without the pass's persist having reported success. What `Banked` claims is exactly that `persisted` input — a saved project was active and the bank write was issued — and the enum comment is that claim's one home.
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns `classifyReaperSurface`, the prefix classifier mapping a `GetThingFromPoint` (info token, track-present) pair onto `core/ui/drag_out`'s `ReaperSurface`. Classifier ordering is load-bearing: the embed strip is matched before the `tcp`/`mcp` panel family, which now claims the WHOLE track panel rather than just its FX sub-elements. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure. - `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.
+139 -19
View File
@@ -187,30 +187,150 @@ BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanK
} }
std::string describeBakeScan(const BakeScanTally& t) { std::string describeBakeScan(const BakeScanTally& t) {
if (t.answered > 0) return {}; // `answered` is pass-wide and counts a QUEUED write, so it cannot stand alone: a pass
// that answered somebody else's key while skipping ours, or that queued our answer and
// had the write rejected, would otherwise print nothing at all and leave the asking
// instance with a no-answer and a silent console.
const int unanswered = t.unreadable + t.notARequest + t.staleCleared;
if (t.answered > 0 && unanswered == 0 && t.writeFailed == 0 && 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") + ", and answered nothing. "; countOf(t.tabsScanned, "project tab") + ". ";
if (t.keysFound == 0) { if (t.keysFound == 0)
s += "No pending bake request was visible to it at all -- if ReaSampler 9000 " return s +
"reported publishing one, the plugin and the extension are not reading the " "No rsbake_ key was visible to it in any of them. That fits a request "
"same project's ext state."; "published into a project this scan did not enumerate, a key cleared "
return s + "\n"; "before the scan reached it, or a key set this session that the "
} "enumeration cannot yet see -- this pass cannot tell which apart.\n";
s += "It found " + countOf(t.keysFound, "pending request key") + ", " + s += "It found " + countOf(t.keysFound, "rsbake_ key") +
std::to_string(t.activeTabKeys) + " of them in the active tab"; " (every such key, not pending requests only), " +
if (t.unreadable > 0) std::to_string(t.activeTabKeys) + " of them in REAPER's active tab. It answered " +
s += "; " + std::to_string(t.unreadable) + " could not be read back"; std::to_string(t.answered);
if (t.notARequest > 0) if (unanswered > 0) {
s += "; " + std::to_string(t.notARequest) + " held something other than a request"; s += " and left " + std::to_string(unanswered) + " unanswered";
if (t.staleCleared > 0) if (t.unreadable > 0)
s += "; " + std::to_string(t.staleCleared) + s += "; " + std::to_string(t.unreadable) + " could not be read back";
" were past the age bound and were cleared unanswered"; if (t.notARequest > 0)
s += "; " + std::to_string(t.notARequest) +
" held something other than a request";
// "were cleared" would assert a write this count cannot see: whether each clear
// actually took is a per-KEY read-back, and describeBakeKey is where it is said.
if (t.staleCleared > 0)
s += "; " + std::to_string(t.staleCleared) +
" were past the age bound, so no reader was left to answer";
}
s += "."; s += ".";
if (t.activeTabKeys == 0) if (t.writeFailed > 0)
s += " The tab this bake was fired against held none of them."; s += " " + countOf(t.writeFailed, "answer") +
" could not be written back into the project, so whoever asked for them sees "
"no answer at all.";
if (t.writeUnproven > 0)
s += " " + countOf(t.writeUnproven, "answer") +
" could not be checked after writing, so whether they reached the project is "
"unknown.";
return s + "\n"; return s + "\n";
} }
BakeLanding bakeLandingAfterPersist(BakeLanding landing, bool persisted) {
if (landing == BakeLanding::Unpersisted && persisted) return BakeLanding::Banked;
return landing;
}
namespace {
// The Land verdict's own clause, with its own fail-closed default — an unnamed enumerator
// must not fall out empty and get reported by the caller as an unnamed VERDICT, which is a
// different gap in a different enum.
std::string describeLanding(BakeLanding landing) {
switch (landing) {
case BakeLanding::Banked:
// Bounded by what `persisted` actually observed (see BakeLanding): a saved
// project was active and the write was issued. The trailing clause is an
// instruction, not a claim about the .rpp's current contents.
return "landed into the bank, and this pass issued its bank write into the "
"saved project -- save the project to keep it";
case BakeLanding::Unpersisted:
// Says nothing about what the project holds: the persist may never have run at
// all, and a dedup hit's target may have been in the project since long before
// this pass.
return "landed into the bank IN MEMORY ONLY -- this pass's persist did not "
"report success, so nothing here can say the project carries it";
case BakeLanding::Partial:
return "the landing failed after it had begun writing -- it may have left a "
"file in the bank folder and an entry in memory";
case BakeLanding::Refused:
return "the landing was refused";
}
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
std::string describeBakeKey(const std::string& key, const BakeKeyOutcome& outcome) {
const std::string head = "ReaSampler resample: " + key + " -- ";
std::string clause; // set only by the verdicts that write something back
switch (outcome.verdict) {
case BakeScanVerdict::IgnoreUnreadable:
return head + (outcome.oversized
? "skipped: its value was too large to read back whole.\n"
: "skipped: the enumerator listed it but it read back "
"empty.\n");
case BakeScanVerdict::IgnoreNotARequest:
return head +
"skipped: it holds something other than a pending request -- an answer "
"nobody has collected, or a wire this build does not read.\n";
case BakeScanVerdict::ClearStale: {
// The clear is a write like any other, so it is claimed only where it was read
// back: an unconfirmed one leaves a value standing for the next pass.
const std::string aged = head + "past the age bound with no reader left";
switch (outcome.proof) {
case BakeWriteProof::Confirmed:
return aged + ", so it was cleared unanswered.\n";
case BakeWriteProof::Rejected:
// Not "could not be read back": Rejected also covers a clear that was
// never issued, where no read happened at all. What holds either way is
// the consequence.
return aged +
", but the clear did NOT take -- the key still holds a value and "
"the next pass will see it again.\n";
case BakeWriteProof::Unknown:
break;
}
return aged +
"; whether the clear took is unknown -- the write-and-check itself "
"failed.\n";
}
case BakeScanVerdict::RefuseWrongProject:
clause = "refused";
break;
case BakeScanVerdict::Land:
clause = describeLanding(outcome.landing);
break;
}
// Fails closed on a verdict this build has no word for, the same reason
// answeredOutcome exists: no -Wswitch is configured, so an appended enumerator would
// otherwise fall straight into the trailing clause and print half a sentence.
if (clause.empty()) return head + "a verdict this build has no word for.\n";
std::string s = head + clause;
if (!outcome.detail.empty()) s += " (" + outcome.detail + ")";
return s + describeProof(outcome.proof);
}
} // namespace reasampler::wire } // namespace reasampler::wire
+83 -12
View File
@@ -85,12 +85,14 @@ std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire);
// evidence either side gets. The two classifiers below are that reading, stated once. // evidence either side gets. The two classifiers below are that reading, stated once.
// What the instrument found under its own key once invokeExtensionAction returned. The // What the instrument found under its own key once invokeExtensionAction returned. The
// distinction that matters: `Unanswered` is the extension never having read the key at // distinction that matters: `Unanswered` is nothing having WRITTEN over the key, which is
// all, which is a DIFFERENT fault from every refusal — a refusal is an outcome. // a DIFFERENT fault from every refusal — a refusal is an outcome. It does NOT mean the
// landing never read the key: a scan that skipped this key, and a scan whose answer write
// was rejected, both leave the request sitting here identically.
enum class BakeAnswerKind { enum class BakeAnswerKind {
Answered, // a decodable outcome echoing this request's generation Answered, // a decodable outcome echoing this request's generation
ForeignOutcome, // a decodable outcome, but for another generation ForeignOutcome, // a decodable outcome, but for another generation
Unanswered, // this request, unchanged: nothing on the extension side read it Unanswered, // this request, unchanged: nothing wrote an outcome over it
ForeignRequest, // a request that is not ours — another instance shares this key ForeignRequest, // a request that is not ours — another instance shares this key
Cleared, // the key holds nothing — absent, explicitly cleared, or a read Cleared, // the key holds nothing — absent, explicitly cleared, or a read
// failure at the bridge; these are not distinguishable from here // failure at the bridge; these are not distinguishable from here
@@ -161,20 +163,89 @@ BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanK
// request, untouched — and only these counts say which of them happened. // request, untouched — and only these counts say which of them happened.
struct BakeScanTally { struct BakeScanTally {
int tabsScanned = 0; int tabsScanned = 0;
int keysFound = 0; // `rsbake_*` keys enumerated across every open tab int keysFound = 0; // EVERY `rsbake_*` key enumerated across every open tab
int activeTabKeys = 0; // of those, in the tab the action was fired against // uncollected outcomes and unreadable values included, not
// pending requests only
int activeTabKeys = 0; // of those, in REAPER's active tab. NOT "the tab the bake was
// fired against": the action is invoked with the instance's
// own project as `proj`, and whether REAPER makes that project
// current for the action's duration is `[verify — DAW]`
int unreadable = 0; // IgnoreUnreadable int unreadable = 0; // IgnoreUnreadable
int notARequest = 0; // IgnoreNotARequest int notARequest = 0; // IgnoreNotARequest
int staleCleared = 0; // ClearStale int staleCleared = 0; // ClearStale
int answered = 0; // an outcome was written back (a landing OR a refusal) int answered = 0; // an outcome was QUEUED for write-back (a landing OR a
int landed = 0; // of `answered`, the ones that reached the bank // refusal) — pass-wide, not per key, and not proof the write
// reached the project; `writeFailed`/`writeUnproven` are that
int writeFailed = 0; // of `answered`, the ones whose value read back as something
// other than what was written — an asking instance sees these
// as no answer at all
int writeUnproven = 0; // of `answered`, the ones whose write-and-check itself failed:
// NOT known to have landed and NOT known to have failed
}; };
// The console sentence for a pass that answered NOTHING — the one state in which the // The console summary for one pass. Empty ONLY when the pass answered somebody AND left no
// asking instance reports a no-answer and has nothing to go on. Empty string when // key unanswered AND every answer was read back from its own key — so the summary's absence
// `answered > 0`, so the caller prints unconditionally and stays quiet on a pass that // means nothing went wrong, never that the action did not run. A silent console is NOT
// spoke for itself. The line's absence is itself evidence: no line means the landing // evidence the landing never ran; nothing here can observe that, and no caller may say it.
// action never ran.
std::string describeBakeScan(const BakeScanTally& tally); std::string describeBakeScan(const BakeScanTally& tally);
// How far a `Land` verdict actually got. `Banked` is reachable ONLY through
// bakeLandingAfterPersist below — a landing may not claim the word on its own, because
// nothing a landing can see says whether the pass's persist ran. What `Banked` claims is
// exactly its `persisted` input and nothing more; this is that claim's one home.
enum class BakeLanding {
Refused, // nothing was written and the book is untouched
Partial, // it threw AFTER it had begun writing — a file and/or an entry may exist
Unpersisted, // it reached the in-memory book; the pass's persist did not report success
Banked, // in the book, and the pass issued its bank write into a saved project
};
// The ONE route to `Banked`: every Land verdict's landing is assigned `Unpersisted` and
// then passed through here. The special case this replaces — a dedup hit answered `Banked`
// directly on the grounds that it changed nothing — was wrong precisely when the entry it
// deduped against was one the SAME pass had just added and then failed to persist.
//
// `persisted` is ReaSamplerSession::saveToActiveProject's return: a saved project was
// active and the bank write was ISSUED into it. That is the whole claim — not that REAPER
// took the value, not that any sibling key was written, and not that the .rpp on disk holds
// it, which REAPER writes on the project's own save.
//
// The `Unpersisted` limb survives that narrowing: under a Land verdict the persist's own
// two refusals are already excluded, so what reaches it is a persist that did not RUN —
// bake_land assigns the landing inside a guarded scan and sets its `persisted` local only
// in the block after it, so a throw between the two leaves a landed entry with the flag
// still false.
BakeLanding bakeLandingAfterPersist(BakeLanding landing, bool persisted);
// Whether the write a verdict required actually took. Three states, not two: the read-back
// 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, // the write did not take: it read back as something else, or it was never
// issued at all
Unknown, // the write was issued and the check itself failed; neither claim available
};
// What ONE scanned key ended the pass in — the per-key half of the tally above, which
// counts but cannot name. Every field but the verdict is verdict-conditional; the phrasing
// function is the one place that pairing is spelled out.
struct BakeKeyOutcome {
BakeScanVerdict verdict = BakeScanVerdict::IgnoreUnreadable;
bool oversized = false; // IgnoreUnreadable only: the value exceeded the read ceiling
// rather than reading back empty
BakeLanding landing = BakeLanding::Refused; // Land only
BakeWriteProof proof = BakeWriteProof::Unknown; // Land / RefuseWrongProject /
// ClearStale: whether the write this
// verdict required took
std::string detail; // the outcome's own message, so one line per key is
// self-contained; empty for the verdicts without one, and empty
// wherever the landing clause already makes that same claim
};
// One console line naming a key and what the pass did with it, ends in '\n'. Printed for
// EVERY enumerated key, answered or not, because the counts above cannot tell an instance
// which key was its own — and the key carries the asking instance's guid.
std::string describeBakeKey(const std::string& key, const BakeKeyOutcome& outcome);
} // namespace reasampler::wire } // namespace reasampler::wire
+43 -3
View File
@@ -1,7 +1,8 @@
#pragma once #pragma once
// ext_state_read — the GetProjExtState grow-loop retry policy, shared by the // ext_state_read — the GetProjExtState grow-loop retry policy (shared by the
// extension's persist/usage-scan shells and the instrument's bridge so the // extension's persist/usage-scan/landing shells and the instrument's bridge) AND
// retry/termination rules cannot drift between them. // its peer, the read-back verdict that says whether a write took — that one bound
// to the instrument's per-instance keys only, for the reason stated at it below.
// //
// GetProjExtState writes into a caller-supplied buffer with no query-the-size // GetProjExtState writes into a caller-supplied buffer with no query-the-size
// call, so a large value must be read by growing a buffer until it fits // call, so a large value must be read by growing a buffer until it fits
@@ -21,6 +22,7 @@
// in a lambda. // in a lambda.
#include <cstddef> #include <cstddef>
#include <optional>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -62,4 +64,42 @@ GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) {
return result; return result;
} }
// Did the write we just made take? `readBack` is what the key holds afterwards, and
// `nullopt` means ABSENT specifically — an Overflow read is NOT an absence and the caller
// must route it to its own "could not check" answer rather than folding it in here, or a
// clear would be claimed off a value nothing could read. This exists because
// SetProjExtState's return is the size of the WHOLE extname's state: `banks`,
// `project_guid` and every rsusage_/rsbake_ key count toward it, so it is non-zero whenever
// ANY key under the extname holds a value — keys this write never touched included. On the
// bake's landing path, where `banks` is necessarily already populated (the landing found
// its source there), it is non-zero unconditionally. That return therefore cannot separate
// a key that took the value from one that did not, and re-reading the key is the only
// per-key observation available under a shared extname. An empty `written` is a CLEAR,
// which lands as an absent-or-empty key rather than as those bytes.
//
// SCOPE — the per-instance keys the two artifacts exchange across the plugin/extension
// boundary. The one that earns it is the bake's `rsbake_<guid>`: the instrument publishes a
// request, REAPER dispatches the action, the extension answers over the same key, and an
// instance finding nothing there genuinely cannot tell "never written" from "written and
// did not stick" by any other means. (The `rsusage_` publish rides along because it shares
// the bridge's one prefix-guarded writer; its verdict is currently unread.)
//
// Do NOT bind it to a write both made and read by one call in one process, and above all
// not to one whose verdict becomes CONTROL FLOW: this is a PROOF VALUE reported to the
// user, never a decision about what to do next. `saveToActiveProject` is the standing
// counter-example — its return IS control flow, over undo points — so do not reintroduce
// a read-back there.
//
// `[verify — DAW]` byte equality assumes REAPER stores and returns an ext-state value
// verbatim. Two known ways that could be false: a value REAPER normalises on the round
// trip, and an embedded NUL (SetProjExtState/GetProjExtState are C-string transports, so a
// payload containing one is truncated on write). Either would report a write REAPER did
// take as unconfirmed — tolerable for a proof value, which is the only thing this may
// answer, and the reason the scope above is a hard limit rather than a preference.
inline bool extStateWriteLanded(const std::string& written,
const std::optional<std::string>& readBack) {
if (written.empty()) return !readBack || readBack->empty();
return readBack && *readBack == written;
}
} // namespace reasampler::wire } // namespace reasampler::wire
+20 -5
View File
@@ -305,6 +305,16 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
return out; return out;
} }
// The clause an ingest success line needs when the persist no-opped. saveToActiveProject
// returns false for exactly two reasons, no active project and an unsaved one, and in both
// the copied file is on disk with its entry in memory while NOTHING stored the index — so
// no message may report the import flatly.
std::string unsavedProjectNote(bool persisted) {
if (persisted) return {};
return " The bank index is IN MEMORY ONLY -- there is no saved project to store it in, "
"so save the project to keep it.";
}
// Imports the Media Explorer's last-played/selected file into the active bank, then // Imports the Media Explorer's last-played/selected file into the active bank, then
// adds a ReaSampler 9000 instrument to the FIRST SELECTED TRACK pre-loaded with that // adds a ReaSampler 9000 instrument to the FIRST SELECTED TRACK pre-loaded with that
// sound — no new track, no routing changes. No assignment_request write. // sound — no new track, no routing changes. No assignment_request write.
@@ -350,10 +360,11 @@ void doImportFromMediaExplorer() {
if (!target) { if (!target) {
// Bank import is kept (sound is in the bank browser); generation is bumped // Bank import is kept (sound is in the bank browser); generation is bumped
// so any open VST3 browser instances refresh to show the new sound. // so any open VST3 browser instances refresh to show the new sound.
bool persisted = true; // true when nothing needed persisting (dedup)
if (r.added) { if (r.added) {
Undo_BeginBlock2(nullptr); Undo_BeginBlock2(nullptr);
g_session->bumpBankGeneration(); g_session->bumpBankGeneration();
const bool persisted = g_session->saveToActiveProject(); persisted = g_session->saveToActiveProject();
if (persisted) if (persisted)
Undo_EndBlock2(nullptr, "ReaSampler: import Media Explorer file into bank", Undo_EndBlock2(nullptr, "ReaSampler: import Media Explorer file into bank",
UNDO_STATE_MISCCFG); UNDO_STATE_MISCCFG);
@@ -364,7 +375,8 @@ void doImportFromMediaExplorer() {
ShowConsoleMsg(("ReaSampler ingest: " + r.message + ShowConsoleMsg(("ReaSampler ingest: " + r.message +
" -- select a track first, then import into it " " -- select a track first, then import into it "
"(sound is in the bank but no instrument was placed because " "(sound is in the bank but no instrument was placed because "
"no track was selected).\n").c_str()); "no track was selected)." + unsavedProjectNote(persisted) +
"\n").c_str());
return; return;
} }
@@ -392,10 +404,12 @@ void doImportFromMediaExplorer() {
bankPanelRefresh(); bankPanelRefresh();
if (placed) if (placed)
ShowConsoleMsg(("ReaSampler ingest: " + r.message + ShowConsoleMsg(("ReaSampler ingest: " + r.message +
" (loaded into a new instrument on the selected track).\n").c_str()); " (loaded into a new instrument on the selected track)." +
unsavedProjectNote(persisted) + "\n").c_str());
else else
ShowConsoleMsg(("ReaSampler ingest: imported to the bank (" + r.message + ShowConsoleMsg(("ReaSampler ingest: imported to the bank (" + r.message +
") but could not add the instrument to the selected track.\n").c_str()); ") but could not add the instrument to the selected track." +
unsavedProjectNote(persisted) + "\n").c_str());
} }
} // namespace } // namespace
@@ -446,7 +460,8 @@ void ingestDroppedFiles(const std::vector<std::string>& absolutePaths) {
bankPanelRefresh(); bankPanelRefresh();
const std::string msg = const std::string msg =
"ReaSampler ingest: imported " + std::to_string(importedTotal) + "ReaSampler ingest: imported " + std::to_string(importedTotal) +
(importedTotal == 1 ? " file" : " files") + " into the bank.\n"; (importedTotal == 1 ? " file" : " files") + " into the bank." +
unsavedProjectNote(persisted) + "\n";
ShowConsoleMsg(msg.c_str()); ShowConsoleMsg(msg.c_str());
} else if (importedTotal > 0) { } else if (importedTotal > 0) {
bankPanelRefresh(); bankPanelRefresh();
+10 -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, and printing `wire::describeBakeScan` when the pass answered nobody. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. 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::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing whose pass never got its persist through 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,3 +90,11 @@ detail not covered there:
- **Realtime is the one capture path that accepts a multi-track selection**, and it is - **Realtime is the one capture path that accepts a multi-track selection**, and it is
correct to: its per-source-track sends sum in the one temp track, which is a real mix correct to: its per-source-track sends sum in the one temp track, which is a real mix
rather than a stem collapse. The offline refusal above does not apply to it. rather than a stem collapse. The offline refusal above does not apply to it.
- **`bake_land`'s panel refresh and its generation bump can disagree after a throw.**
The refresh rides `bookChanged` outside the guarded scan, while the bump sits inside
it — so a pass that landed an entry and then threw before reaching the persist block
repaints the docked panel from the in-memory book without having bumped the bank
generation, and other open instances stay on the old generation until the next bump.
Accepted: the panel showing what the pass actually did is the more useful of the two,
and the next bank mutation reconciles it. Do not "fix" it by moving the refresh inside
the try — that would trade a stale generation for a stale panel.
+229 -251
View File
@@ -1,30 +1,23 @@
// 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 <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/panel/panel_input.h" // bankPanelRefresh #include "shell/capture/bake_landing.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/persist/session.h" #include "shell/persist/session.h"
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG #include "reaper_plugin.h" // UNDO_STATE_MISCCFG
@@ -43,168 +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;
}
// Lands ONE request into `session`'s book, which must already be the book of the project
// `projectDir` names. Mutates book + ledger without persisting; the caller persists once for
// the batch. Every refusal path leaves the book untouched and writes no file, so a failed
// bake is invisible to the project.
BakeOutcome landOne(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
if (projectDir.empty())
return refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location", request.generation);
const std::vector<std::uint8_t> bytes = util::readFileBytes(request.stagedFilePath);
if (bytes.empty())
return refuse(BakeStatus::StagedMissing, "the staged render was unreadable",
request.generation);
const WavLayout layout = parseWavLayout(bytes);
if (!layout.valid || layout.frameCount() == 0)
return refuse(BakeStatus::StagedMissing, "the staged render is not a usable WAV",
request.generation);
BankBook& book = session.book();
std::string bankId;
const Sample* source = findSourceSample(book, request.sourceSampleId, bankId);
if (!source)
return refuse(BakeStatus::NoSource, "the resampled capture is not in any bank",
request.generation);
// Copied, not aliased: every mutation below invalidates the book's pointers.
const Sample sourceCopy = *source;
const tracking::Landing landing = tracking::resampleLanding(
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
const bool 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(bytes);
BakeOutcome out;
out.generation = request.generation;
out.rootNote = request.rootNote;
out.channelCount = static_cast<int>(layout.channelCount);
out.replaced = replace;
if (!replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(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.
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";
return out;
}
}
}
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);
std::error_code ec;
fs::create_directories(paths.absoluteDir, ec); // idempotent; the write reports failure
const std::string destPath = paths.absoluteDir + "/" + paths.fileName;
if (!writeFileBytes(destPath, bytes))
return refuse(BakeStatus::Failed, "could not write the bake into the bank folder",
request.generation);
Sample landed;
// 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.
landed.id = replace ? sourceCopy.id
: ("bake-" + uniqueTag + "-" + paths.fileName);
landed.displayName =
replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
landed.relativePath = paths.relativePath; // project-relative (invariant)
landed.channelCount = static_cast<int>(layout.channelCount);
landed.sampleRate = static_cast<int>(layout.sampleRate);
landed.lengthSeconds =
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
static_cast<double>(layout.sampleRate)
: 0.0;
landed.rootNote = request.rootNote; // rendered AT root — that is what makes it survive
landed.tier = model::Tier::Scratch;
landed.contentHash = contentHash;
landed.createdTimestamp = nowSec;
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
// repeated bake readable as one iteration chain.
landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
const bool indexed = replace
? book.updateSampleInPlace(sourceCopy.id, landed)
: (book.index(bankId) &&
book.index(bankId)->add(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(destPath, ec);
return refuse(BakeStatus::IndexRejected,
replace ? "the bank refused the replacement"
: "the bank refused the new capture",
request.generation);
}
session.recordCreated(landed, tracking::OriginKind::Capture);
out.status = BakeStatus::Ok;
out.sampleId = landed.id;
out.relativePath = landed.relativePath;
out.displayName = landed.displayName;
out.message = replace ? "replaced the bank entry" : "added as a distinct capture";
return out;
}
// 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) {
@@ -222,12 +57,22 @@ std::vector<std::string> pendingBakeKeys(ReaProject* proj) {
return keys; return keys;
} }
std::optional<std::string> readKey(ReaProject* proj, const std::string& key) { // Whole value, or the reason there isn't one. Absent and Overflow both leave the scan with
// nothing to decode — one verdict — but they are different faults to go fix, so the reason
// survives to the per-key console line rather than collapsing here.
struct KeyRead {
std::optional<std::string> value;
bool oversized = false; // the value never fit under the grow loop's ceiling
};
KeyRead readKey(ReaProject* proj, const std::string& key) {
const auto read = wire::readProjExtStateGrowing([&](char* buf, int cap) { const auto read = wire::readProjExtStateGrowing([&](char* buf, int cap) {
return GetProjExtState(proj, kProjExtNamespace(), key.c_str(), buf, cap); return GetProjExtState(proj, kProjExtNamespace(), key.c_str(), buf, cap);
}); });
if (read.status != wire::GrowingExtStateRead::Status::Complete) return std::nullopt; KeyRead out;
return read.value; out.oversized = read.status == wire::GrowingExtStateRead::Status::Overflow;
if (read.status == wire::GrowingExtStateRead::Status::Complete) out.value = read.value;
return out;
} }
struct OpenProject { struct OpenProject {
@@ -249,21 +94,97 @@ std::vector<OpenProject> openProjects() {
return out; return out;
} }
// One answer to write back after the undo block closes. // One scanned key: what the pass decided about it, and what to write back after the undo
struct Answer { // block closes. The outcome is kept UNENCODED until then, because the pass's persist runs
// after the whole scan and an Ok the project never took must not be sent as one.
struct ScannedKey {
enum class Write { None, Clear, Answer };
ReaProject* proj = nullptr; ReaProject* proj = nullptr;
std::string key; std::string key;
std::string wire; // empty = clear the key instead of answering it Write write = Write::None; // None = an Ignore verdict, nothing to write
std::string console; // empty = nothing to print BakeOutcome outcome; // Write::Answer only
wire::BakeKeyOutcome report;
}; };
// Undo_BeginBlock2/EndBlock2 as stack RAII, the FxBypassGuard discipline: a landing reads a
// whole WAV and hashes it, so a bad_alloc between the two calls would otherwise leave an
// open undo block in the user's project. Closing with an empty description records no undo
// point, which is what every path that landed nothing wants anyway.
class UndoBlock {
public:
UndoBlock() { Undo_BeginBlock2(nullptr); }
~UndoBlock() { Undo_EndBlock2(nullptr, description_.c_str(), flags_); }
void recordPoint(std::string description, int flags) {
description_ = std::move(description);
flags_ = flags;
}
UndoBlock(const UndoBlock&) = delete;
UndoBlock& operator=(const UndoBlock&) = delete;
private:
std::string description_;
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. Which proof a throw yields turns on whether SetProjExtState had run: before
// it, the write is KNOWN not to have been made (`Rejected`); after it, the write may well
// have landed and only the check failed (`Unknown`).
wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
bool issued = false;
try {
std::string value; // empty = clear the key
if (entry.write == ScannedKey::Write::Answer) {
// Where a landing earns the word: the pass's persist runs after the whole scan,
// so until here no bank write had been issued for it at all. Without one 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());
issued = true;
// Only re-reading THIS key can speak for it (wire::extStateWriteLanded owns why).
// An oversized read proves nothing either way — folding it into that predicate's
// absent case would report a clear off unread bytes.
const KeyRead back = readKey(entry.proj, entry.key);
if (back.oversized) return wire::BakeWriteProof::Unknown;
return wire::extStateWriteLanded(value, back.value) ? wire::BakeWriteProof::Confirmed
: wire::BakeWriteProof::Rejected;
} catch (...) {
return issued ? wire::BakeWriteProof::Unknown : wire::BakeWriteProof::Rejected;
}
}
} // namespace } // namespace
void RunResampleBake(ReaSamplerSession& session) { void RunResampleBake(ReaSamplerSession& session) {
// Whether REAPER makes Main_OnCommandEx's `proj` current for the action's duration is
// unverified in the DAW; the per-key verdict holds either way, and a request it cannot
// land is told why rather than silently ignored.
const void* loaded = session.loadedProject(); const void* loaded = session.loadedProject();
// REAPER's CURRENT tab, which is all this is. The action was invoked with the asking
// instance's own project as `proj` — getReaperParent(3), documented as the parent
// project in reaper_vst3_interfaces.h, and already paired with an EnumProjects-derived
// enumeration by the shipped rsusage_* path prune's protection depends on. What is
// `[verify — DAW]` is only whether REAPER makes that `proj` CURRENT for the action's
// duration, so nothing below may call this "the tab the bake was fired against". The
// per-key verdict holds either way, and a request this pass cannot land is told why
// rather than silently ignored.
const void* active = EnumProjects(-1, nullptr, 0); const void* active = EnumProjects(-1, nullptr, 0);
// `loaded == active` alone is enough: every OpenProject::proj enumerated below is a // `loaded == active` alone is enough: every OpenProject::proj enumerated below is a
// real, non-null tab, so an unloaded `loaded` (nullptr) can never equal one and // real, non-null tab, so an unloaded `loaded` (nullptr) can never equal one and
@@ -271,89 +192,146 @@ void RunResampleBake(ReaSamplerSession& session) {
const wire::BakeScanContext scanContext{loaded == active}; const wire::BakeScanContext scanContext{loaded == active};
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr)); const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
std::vector<Answer> answers; std::vector<ScannedKey> scanned;
wire::BakeScanTally tally; // every verdict below is counted, skips included wire::BakeScanTally tally; // every verdict below is counted, skips included
Undo_BeginBlock2(nullptr); std::string aborted; // set only when the scan itself threw
for (const OpenProject& open : openProjects()) { bool persisted = false; // the pass's ONE persist ran and reported success
++tally.tabsScanned; bool bookChanged = false; // some landing added or refreshed an entry
const bool activeTab = static_cast<const void*>(open.proj) == active; // A local, not a tally field: an entry counted here can still be re-answered as a
for (const std::string& key : pendingBakeKeys(open.proj)) { // failure by the write-back loop below, so this is a persist GATE and never a count
++tally.keysFound; // anything reports.
if (activeTab) ++tally.activeTabKeys; bool anyLanded = false;
const std::optional<std::string> raw = readKey(open.proj, key); // The scan allocates outside attemptLanding's own guards too (readKey grows a buffer
const std::optional<BakeRequest> request = // toward 16 MB, every container here allocates), and a throw that escaped would discard
raw ? wire::decodeBakeRequest(*raw) : std::nullopt; // the answers already buffered — the exact no-answer-with-a-silent-console this action
const wire::BakeScanKey scanKey{ // exists to make impossible. UndoBlock is inside the try, so unwinding still closes it.
raw.has_value(), request.has_value(), request ? request->generation : 0, try {
static_cast<const void*>(open.proj) == loaded}; UndoBlock undo;
const wire::BakeScanVerdict verdict = for (const OpenProject& open : openProjects()) {
wire::classifyBakeScan(scanContext, scanKey, nowSec); ++tally.tabsScanned;
const bool activeTab = static_cast<const void*>(open.proj) == active;
for (const std::string& key : pendingBakeKeys(open.proj)) {
++tally.keysFound;
if (activeTab) ++tally.activeTabKeys;
const KeyRead read = readKey(open.proj, key);
const std::optional<BakeRequest> request =
read.value ? wire::decodeBakeRequest(*read.value) : std::nullopt;
const wire::BakeScanKey scanKey{
read.value.has_value(), request.has_value(),
request ? request->generation : 0,
static_cast<const void*>(open.proj) == loaded};
const wire::BakeScanVerdict verdict =
wire::classifyBakeScan(scanContext, scanKey, nowSec);
// Leave it — the writing instance owns clearing its own key. ScannedKey entry;
if (verdict == wire::BakeScanVerdict::IgnoreUnreadable) { entry.proj = open.proj;
++tally.unreadable; entry.key = key;
continue; entry.report.verdict = verdict;
} entry.report.oversized = read.oversized;
if (verdict == wire::BakeScanVerdict::IgnoreNotARequest) {
++tally.notARequest;
continue;
}
if (verdict == wire::BakeScanVerdict::ClearStale) {
++tally.staleCleared;
answers.push_back(Answer{open.proj, key, std::string{}, std::string{}});
continue;
}
BakeOutcome outcome; // Leave it — the writing instance owns clearing its own key.
if (verdict == wire::BakeScanVerdict::RefuseWrongProject) { if (verdict == wire::BakeScanVerdict::IgnoreUnreadable) {
outcome = refuse(BakeStatus::WrongProject, ++tally.unreadable;
"this bake's project tab is not the one the extension has " scanned.push_back(std::move(entry));
"loaded -- focus that tab and try again", continue;
request->generation); }
} else { // `!request` is folded in here rather than dereferenced below: today
outcome = landOne(session, open.dir, *request); // classifyBakeScan filters every undecoded key into this verdict, but a
if (outcome.status == BakeStatus::Ok) ++tally.landed; // future verdict that did not would otherwise reach *request unguarded.
if (verdict == wire::BakeScanVerdict::IgnoreNotARequest || !request) {
++tally.notARequest;
entry.report.verdict = wire::BakeScanVerdict::IgnoreNotARequest;
scanned.push_back(std::move(entry));
continue;
}
if (verdict == wire::BakeScanVerdict::ClearStale) {
++tally.staleCleared;
entry.write = ScannedKey::Write::Clear;
scanned.push_back(std::move(entry));
continue;
}
if (verdict == wire::BakeScanVerdict::RefuseWrongProject) {
entry.outcome =
refuseBake(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension "
"has loaded -- focus that tab and try again",
request->generation);
} else {
LandingAttempt attempt = attemptLanding(session, open.dir, *request);
if (attempt.outcome.status == BakeStatus::Ok) anyLanded = true;
if (attempt.changedBook) bookChanged = true;
// Never Banked here, on ANY path: the pass's persist has not run yet,
// and bakeLandingAfterPersist is the one place that word is earned. A
// 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)
entry.report.landing = wire::BakeLanding::Partial;
else if (attempt.outcome.status != BakeStatus::Ok)
entry.report.landing = wire::BakeLanding::Refused;
else
entry.report.landing = wire::BakeLanding::Unpersisted;
entry.outcome = std::move(attempt.outcome);
}
++tally.answered; // QUEUED, not written — the write loop below judges that
entry.write = ScannedKey::Write::Answer;
scanned.push_back(std::move(entry));
} }
++tally.answered;
// A WrongProject request left sitting in a tab this call did not come from
// prints again on every OTHER tab's bake, since the scan revisits every open
// project each time. `active` is this call's own proxy for "the invoking tab" —
// only that tab's own refusal is fresh feedback to a user who just clicked
// bake; every other one is a rescan repeat.
std::string console;
if (outcome.status != BakeStatus::Ok &&
(outcome.status != BakeStatus::WrongProject || activeTab)) {
console = "ReaSampler resample: " + outcome.message + ".\n";
}
answers.push_back(
Answer{open.proj, key, wire::encodeBakeOutcome(outcome), console});
} }
}
if (tally.landed > 0) { if (anyLanded) {
// A bake changes what a live instance would play, so the generation bump rides the // A dedup hit rides the persist too, because that is the only observation that
// 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
const bool persisted = session.saveToActiveProject(); // that banked nothing.
Undo_EndBlock2(nullptr, if (bookChanged) session.bumpBankGeneration();
persisted ? "ReaSampler: resample bake into bank" : "", persisted = session.saveToActiveProject();
persisted ? UNDO_STATE_MISCCFG : 0); if (persisted && bookChanged)
} else { undo.recordPoint("ReaSampler: resample bake into bank", UNDO_STATE_MISCCFG);
Undo_EndBlock2(nullptr, "", 0); // nothing landed — record no empty undo point }
} catch (...) {
aborted =
"ReaSampler resample: the landing pass stopped early on an internal failure. "
"The answers it had already prepared are reported below; any key whose answer "
"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.
for (const Answer& answer : answers) { std::string console = aborted;
SetProjExtState(answer.proj, kProjExtNamespace(), answer.key.c_str(), try {
answer.wire.c_str()); for (ScannedKey& entry : scanned) {
if (!answer.console.empty()) ShowConsoleMsg(answer.console.c_str()); if (entry.write != ScannedKey::Write::None) {
entry.report.proof = writeBackOne(entry, persisted);
if (entry.write == ScannedKey::Write::Answer) {
if (entry.report.proof == wire::BakeWriteProof::Rejected)
++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.
console += wire::describeBakeKey(entry.key, entry.report);
}
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 (...) {
}
} }
// Empty unless the pass answered nobody, so a bake that spoke for itself stays quiet. if (!console.empty()) ShowConsoleMsg(console.c_str());
const std::string report = wire::describeBakeScan(tally); if (bookChanged) bankPanelRefresh();
if (!report.empty()) ShowConsoleMsg(report.c_str());
if (tally.landed > 0) 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
+2 -2
View File
@@ -103,12 +103,12 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules ## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`. - `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. - `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_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 the extension never ran the landing, answered a stale generation, answered in a wire this build cannot read, cleared the request, or refused it. 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.)*
+27 -19
View File
@@ -186,8 +186,13 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
const std::string key = bakeKeyFor(instanceGuid); const std::string key = bakeKeyFor(instanceGuid);
RequestKeyGuard keyGuard(bridge, key); RequestKeyGuard keyGuard(bridge, key);
// The bridge PROVES this by reading the key back, so a false here is real: the
// extension would find nothing to land. It does not say which of the three ways failed,
// so neither does this sentence.
if (!bridge.writeBakeExtState(key, wire::encodeBakeRequest(request))) if (!bridge.writeBakeExtState(key, wire::encodeBakeRequest(request)))
return fail("could not publish the bake request"); return fail("the bake request under " + key +
" could not be confirmed -- it was either never written or did not "
"read back as written, so the extension has nothing to land");
// Synchronous: the extension's landing runs to completion inside this call and writes // Synchronous: the extension's landing runs to completion inside this call and writes
// its outcome back over the same key before returning. // its outcome back over the same key before returning.
@@ -196,34 +201,37 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
// What the key holds now is the only evidence this side gets, and each of the five // What the key holds now is the only evidence this side gets, and each of the five
// non-answers is a different thing to go fix — collapsing them into one sentence is // non-answers is a different thing to go fix — collapsing them into one sentence is
// what made a stale install indistinguishable from a refusal. // what made a stale install indistinguishable from a refusal. All five name the KEY,
// because the landing prints one console line per key it scanned and the key is what
// correlates the two in a multi-instance session.
const wire::BakeAnswer answer = const wire::BakeAnswer answer =
wire::classifyBakeAnswer(bridge.readReasamplerExtState(key), request); wire::classifyBakeAnswer(bridge.readReasamplerExtState(key), request);
switch (answer.kind) { switch (answer.kind) {
case wire::BakeAnswerKind::Answered: case wire::BakeAnswerKind::Answered:
break; break;
case wire::BakeAnswerKind::Unanswered: case wire::BakeAnswerKind::Unanswered:
return fail( return fail("the ReaSampler extension did not answer " + key +
"the ReaSampler extension did not answer -- the request key still holds " " -- that key still holds this exact request, untouched. The "
"this exact request, untouched. Check the REAPER console: if the landing " "landing action prints one REAPER console line per key it "
"action printed a scan report just now, it DID run and that line says what " "scanned; look for that key name there");
"it saw; if the console is silent, the action never ran at all");
case wire::BakeAnswerKind::Undecodable: case wire::BakeAnswerKind::Undecodable:
return fail( return fail("the value under " + key +
"the extension answered in a format this plugin does not read -- the " " is neither a request nor an answer this build can read -- the "
"extension and ReaSampler 9000 are from different builds"); "extension and ReaSampler 9000 may be from different builds");
case wire::BakeAnswerKind::Cleared: case wire::BakeAnswerKind::Cleared:
return fail( return fail("the bake key " + key +
"the bake key came back empty -- either the request was cleared before " " came back empty -- either the request was cleared before an "
"an answer was written, or this build could not read whatever was there. " "answer was written, or this build could not read whatever was "
"The landing action's console scan report, if one appeared, names which"); "there. Look for that key name in the landing action's console "
"lines");
case wire::BakeAnswerKind::ForeignRequest: case wire::BakeAnswerKind::ForeignRequest:
return fail( return fail("the bake key " + key +
"the bake key held a different pending request instead of an answer -- " " held a different pending request instead of an answer -- "
"unexpected given the bake's single-threaded call flow; if this recurs, " "unexpected given the bake's single-threaded call flow; if this "
"note the exact steps and file it"); "recurs, note the exact steps and file it");
case wire::BakeAnswerKind::ForeignOutcome: case wire::BakeAnswerKind::ForeignOutcome:
return fail("the extension answered a different bake request"); return fail("the answer under " + key +
" is for a different bake request than this one");
} }
// wire::answeredOutcome is the guard, not switch exhaustiveness alone: the switch // wire::answeredOutcome is the guard, not switch exhaustiveness alone: the switch
// above has no `default`, so a future BakeAnswerKind enumerator it doesn't yet handle // above has no `default`, so a future BakeAnswerKind enumerator it doesn't yet handle
+19 -9
View File
@@ -2,6 +2,8 @@
#include "shell/instrument/reaper_bridge.h" #include "shell/instrument/reaper_bridge.h"
#include <optional>
#include <string>
#include <vector> #include <vector>
#include "core/instrument/map/bridge_marshal.h" #include "core/instrument/map/bridge_marshal.h"
@@ -112,7 +114,9 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
bool ReaperBridge::writeGuarded(const std::string& key, const std::string& value, bool ReaperBridge::writeGuarded(const std::string& key, const std::string& value,
const char* requiredPrefix) { const char* requiredPrefix) {
if (!setProjExtState_ || !hostApp_) return false; // getProjExtState_ is required too: without it the write could not be proven, and an
// unprovable write may not be reported as a landed one.
if (!setProjExtState_ || !getProjExtState_ || !hostApp_) return false;
// Read-only-BANK guard: this module writes the two sanctioned per-instance prefixes // Read-only-BANK guard: this module writes the two sanctioned per-instance prefixes
// and nothing else. Any other key is refused rather than widening the instrument's // and nothing else. Any other key is refused rather than widening the instrument's
// write surface (banks/view/tail/assign stay extension-owned). // write surface (banks/view/tail/assign stay extension-owned).
@@ -121,14 +125,20 @@ bool ReaperBridge::writeGuarded(const std::string& key, const std::string& value
auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_); auto* reaper = static_cast<Steinberg::IReaperHostApplication*>(hostApp_);
void* proj = reaper->getReaperParent(3); // null = current project (same as reads) void* proj = reaper->getReaperParent(3); // null = current project (same as reads)
// SetProjExtState returns the size of the extname's state — after storing a // The return is DELIBERATELY discarded: it describes the whole extname's state, not
// non-empty value that's necessarily > 0, so <= 0 means the write did not land (the // this key, so testing it was a guard that could not fire for the bake publish
// publish path retries next reload tick; a silent drop would leave holds unprotected). // (wire::extStateWriteLanded owns the reasoning). The read-back below is the proof.
// A deliberate CLEAR (empty value) shrinks the state and can legitimately return 0, setProjExtState_(proj, kProjExtNamespace(), key.c_str(), value.c_str());
// so it is reported as landed.
const int rv = const auto back = wire::readProjExtStateGrowing([&](char* buf, int cap) {
setProjExtState_(proj, kProjExtNamespace(), key.c_str(), value.c_str()); return getProjExtState_(proj, kProjExtNamespace(), key.c_str(), buf, cap);
return value.empty() ? true : rv > 0; });
if (back.status == wire::GrowingExtStateRead::Status::Overflow)
return false; // could not check -> report unconfirmed, never a claimed success
return wire::extStateWriteLanded(
value, back.status == wire::GrowingExtStateRead::Status::Complete
? std::optional<std::string>(back.value)
: std::nullopt);
} }
bool ReaperBridge::writeUsageExtState(const std::string& usageKey, bool ReaperBridge::writeUsageExtState(const std::string& usageKey,
+8 -2
View File
@@ -47,10 +47,16 @@ public:
// The instrument's TWO sanctioned ext-state write surfaces, each accepting exactly one // The instrument's TWO sanctioned ext-state write surfaces, each accepting exactly one
// key prefix and refusing every other key. That structural refusal is what keeps the // key prefix and refusing every other key. That structural refusal is what keeps the
// read-only-BANK invariant intact — banks/view/tail/assign stay unwritable from here — // read-only-BANK invariant intact — banks/view/tail/assign stay unwritable from here —
// and neither payload is bank state. Both return true iff the write landed (the // and neither payload is bank state. Neither is RT-safe: the call sites are the
// SetProjExtState return is checked) and neither is RT-safe: the call sites are the
// off-audio-thread reload path and the editor's UI tick. // off-audio-thread reload path and the editor's UI tick.
// //
// Both return true iff the key READ BACK as exactly the value written (an empty value
// is a clear, which lands as an absent-or-empty key). SetProjExtState's own return
// cannot answer that — wire::extStateWriteLanded owns why, and testing it here was a
// guard that could never fire. A `false` does not distinguish a write that was never
// issued (unconnected host, refused prefix) from one that did not take or could not be
// checked; a caller must not name one of the three.
//
// Neither marks the project dirty. A usage change always rides a component-state change // Neither marks the project dirty. A usage change always rides a component-state change
// that already does; a bake request is transient and is cleared in the same tick. // that already does; a bake request is transient and is cleared in the same tick.
+1 -1
View File
@@ -53,7 +53,7 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
## Modules ## Modules
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block. - `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `saveToActiveProject` returns whether the writes were ISSUED — false means no active/saved project and NOTHING was written, which is the only reading its callers' discard-the-undo-point branch is safe under; it must never grow an observational third failure mode (the contract lives at its declaration in `session.h`). `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state. - `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses. - `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
+4
View File
@@ -182,6 +182,10 @@ bool ReaSamplerSession::saveToActiveProject() {
instrument::map::formatBankGeneration(bankGeneration_).c_str()); instrument::map::formatBankGeneration(bankGeneration_).c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj)); MarkProjectDirty(static_cast<ReaProject*>(proj));
// The writes were ISSUED into a saved active project — all this call can observe, and
// deliberately all it claims. Do not "prove" them with a read-back; session.h states
// what a false has to keep meaning to its callers, and why.
return true; return true;
} }
+9 -1
View File
@@ -110,6 +110,12 @@ public:
// Serializes book/view/tail to ext state, clears the retired legacy // Serializes book/view/tail to ext state, clears the retired legacy
// `bank_index` key. No-ops with no active/saved project. Returns true iff // `bank_index` key. No-ops with no active/saved project. Returns true iff
// a persist happened, so a caller can skip an undo block when nothing was written. // a persist happened, so a caller can skip an undo block when nothing was written.
//
// "Happened" is the writes being ISSUED, and a false means exactly two things —
// no active project, or an unsaved one — with NOTHING written under either. SIX
// callers turn a false straight into a discarded undo point, so this must never
// grow a third, OBSERVATIONAL failure mode: a false negative would silently
// remove the Ctrl-Z for a bank mutation that landed.
bool saveToActiveProject(); bool saveToActiveProject();
// Report-only prune dry-run: feeds the pure core with (present, referenced, // Report-only prune dry-run: feeds the pure core with (present, referenced,
@@ -143,7 +149,9 @@ public:
// Write the ingest assignment request (`assign_request` key): "the active // Write the ingest assignment request (`assign_request` key): "the active
// sampler instance should now play THIS sample." `wire` is pre-encoded // sampler instance should now play THIS sample." `wire` is pre-encoded
// (assignment_request.h); a sibling one-shot write, not part of // (assignment_request.h); a sibling one-shot write, not part of
// saveToActiveProject's blob. Returns true iff written. // saveToActiveProject's blob. Same two-condition return as that call: false
// means no active/saved project and nothing was written, true means the write
// was ISSUED — never that the project took it.
bool writeAssignmentRequest(const std::string& wire); bool writeAssignmentRequest(const std::string& wire);
// Detects a project load or Save-As and reacts. Driven by REAPER's // Detects a project load or Save-As and reacts. Driven by REAPER's
+415 -29
View File
@@ -7,14 +7,19 @@
// 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; and the two key classifiers each end reads the shared key through. // its channel fork; the two key classifiers each end reads the shared key through; the
// write-back verdict, driven by a modelled key store that accepts or drops the write; and
// the persist/upgrade state machine that is the ONLY route to a Banked landing, at both
// persist outcomes.
#include "../src/core/wire/bake_wire.h" #include "../src/core/wire/bake_wire.h"
#include "../src/core/version/app_version.h" #include "../src/core/version/app_version.h"
#include "../src/core/wire/ext_state_read.h" // extStateWriteLanded (the write-proof peer)
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <map>
#include <optional> #include <optional>
#include <string> #include <string>
@@ -24,6 +29,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
@@ -392,39 +404,83 @@ int main() {
BakeScanVerdict::IgnoreUnreadable); BakeScanVerdict::IgnoreUnreadable);
} }
// --- The scan report: what the action tells a user it actually saw ------------------ // --- The scan summary: silent ONLY when nothing went unanswered ---------------------
// The report exists because every non-answering verdict leaves the asking instance // `answered` is pass-wide and counts a QUEUED write, so gating on it alone let two real
// with the identical evidence (its own untouched request), so only these counts // faults print nothing: a pass that answered another key while skipping ours, and a
// discriminate them. It must therefore SAY the count that fired, and must stay silent // pass whose own answer write was rejected. Those two are what the gate below pins.
// whenever an answer was written.
{ {
BakeScanTally answeredOne; BakeScanTally clean;
answeredOne.tabsScanned = 1; clean.tabsScanned = 1;
answeredOne.keysFound = 1; clean.keysFound = 1;
answeredOne.activeTabKeys = 1; clean.activeTabKeys = 1;
answeredOne.answered = 1; clean.answered = 1;
answeredOne.landed = 1; CHECK(describeBakeScan(clean).empty());
CHECK(describeBakeScan(answeredOne).empty());
// A refusal is still an answer, so it silences the report the same way a landing
// does — the refusal's own sentence has already been printed.
BakeScanTally refusedOne = answeredOne;
refusedOne.landed = 0;
CHECK(describeBakeScan(refusedOne).empty());
// Nothing found at all: the state Daniel's repro produces if the request key never // THE regression: some OTHER key was answered while ours was skipped. Under the old
// becomes visible to the extension. // `answered > 0` gate this printed nothing at all, and the instrument then told the
// user a silent console proved the action never ran.
for (int BakeScanTally::*skip :
{&BakeScanTally::unreadable, &BakeScanTally::notARequest,
&BakeScanTally::staleCleared}) {
BakeScanTally mixed = clean;
mixed.keysFound = 2;
mixed.*skip = 1;
const std::string said = describeBakeScan(mixed);
CHECK(!said.empty());
CHECK(said.find("left 1 unanswered") != std::string::npos);
}
// THE other regression: our answer was queued (so `answered` counted it) and the
// SetProjExtState write was rejected. Nothing else went wrong, and it must still
// speak.
BakeScanTally rejected = clean;
rejected.writeFailed = 1;
const std::string wrote = describeBakeScan(rejected);
CHECK(!wrote.empty());
CHECK(wrote.find("1 answer could not be written back") != std::string::npos);
CHECK(wrote.find("sees no answer at all") != std::string::npos);
// Plural agrees, and the count is the tally's own, not a hardcoded 1.
BakeScanTally rejectedTwo = clean;
rejectedTwo.answered = 2;
rejectedTwo.writeFailed = 2;
CHECK(describeBakeScan(rejectedTwo).find("2 answers could not be written back") !=
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
// 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.
BakeScanTally nothing; BakeScanTally nothing;
nothing.tabsScanned = 2; nothing.tabsScanned = 2;
const std::string none = describeBakeScan(nothing); const std::string none = describeBakeScan(nothing);
CHECK(!none.empty()); CHECK(!none.empty());
CHECK(none.find("2 project tabs") != std::string::npos); CHECK(none.find("2 project tabs") != std::string::npos);
CHECK(none.find("No pending bake request was visible") != std::string::npos); CHECK(none.find("No rsbake_ key was visible") != std::string::npos);
CHECK(none.find("this pass cannot tell which apart") != std::string::npos);
CHECK(none.find("are not reading the same project") == std::string::npos);
CHECK(none.back() == '\n'); CHECK(none.back() == '\n');
// One tab is singular, not "1 project tabs". // One tab is singular, not "1 project tabs".
BakeScanTally oneTab; BakeScanTally oneTab;
oneTab.tabsScanned = 1; oneTab.tabsScanned = 1;
CHECK(describeBakeScan(oneTab).find("1 project tab,") != std::string::npos); CHECK(describeBakeScan(oneTab).find("1 project tab.") != std::string::npos);
// Found but unreadable — the fact the old scan discarded. // Found but unreadable — the fact the old scan discarded.
BakeScanTally unreadable; BakeScanTally unreadable;
@@ -433,9 +489,12 @@ int main() {
unreadable.activeTabKeys = 1; unreadable.activeTabKeys = 1;
unreadable.unreadable = 1; unreadable.unreadable = 1;
const std::string unread = describeBakeScan(unreadable); const std::string unread = describeBakeScan(unreadable);
CHECK(unread.find("1 pending request key") != std::string::npos);
CHECK(unread.find("1 could not be read back") != std::string::npos); CHECK(unread.find("1 could not be read back") != std::string::npos);
CHECK(unread.find("held something other than a request") == std::string::npos); CHECK(unread.find("held something other than a request") == std::string::npos);
// keysFound counts EVERY rsbake_ key, not pending requests only — a label that
// said otherwise produced "3 pending request keys ... 3 held something else".
CHECK(unread.find("1 rsbake_ key") != std::string::npos);
CHECK(unread.find("pending request key") == std::string::npos);
// Found, readable, but not a request. // Found, readable, but not a request.
BakeScanTally foreign; BakeScanTally foreign;
@@ -447,7 +506,9 @@ int main() {
CHECK(other.find("1 held something other than a request") != std::string::npos); CHECK(other.find("1 held something other than a request") != std::string::npos);
CHECK(other.find("could not be read back") == std::string::npos); CHECK(other.find("could not be read back") == std::string::npos);
// Cleared as stale: an answer was never written, so this too must report. // Cleared as stale: an answer was never written, so this too must report. The
// summary may NOT say the clears were written — whether each one took is a per-key
// read-back, and only describeBakeKey has seen it.
BakeScanTally stale; BakeScanTally stale;
stale.tabsScanned = 1; stale.tabsScanned = 1;
stale.keysFound = 1; stale.keysFound = 1;
@@ -455,17 +516,331 @@ int main() {
stale.staleCleared = 1; stale.staleCleared = 1;
const std::string aged = describeBakeScan(stale); const std::string aged = describeBakeScan(stale);
CHECK(aged.find("past the age bound") != std::string::npos); CHECK(aged.find("past the age bound") != std::string::npos);
CHECK(aged.find("were cleared") == std::string::npos);
// Keys exist, but none in the tab the bake was fired against — the multi-tab // activeTabKeys is REAPER's ACTIVE tab and nothing more. It cannot discriminate the
// mis-target, called out explicitly rather than left to be inferred from "0 of them". // multi-tab mis-target — a genuine background-tab request classifies as
// RefuseWrongProject, which is an ANSWER — so no sentence may claim it does, and
// none may call it "the tab this bake was fired against" (whether REAPER makes the
// invoking instance's project current is DAW-unverified).
BakeScanTally elsewhere; BakeScanTally elsewhere;
elsewhere.tabsScanned = 2; elsewhere.tabsScanned = 2;
elsewhere.keysFound = 1; elsewhere.keysFound = 1;
elsewhere.activeTabKeys = 0; elsewhere.activeTabKeys = 0;
elsewhere.notARequest = 1; elsewhere.notARequest = 1;
const std::string away = describeBakeScan(elsewhere); const std::string away = describeBakeScan(elsewhere);
CHECK(away.find("0 of them in the active tab") != std::string::npos); CHECK(away.find("0 of them in REAPER's active tab") != std::string::npos);
CHECK(away.find("fired against held none of them") != std::string::npos); CHECK(away.find("fired against") == std::string::npos);
}
// --- The per-key line: the only thing that names WHICH key ---------------------------
// The tally counts; it cannot say whose key was skipped. This line is printed for every
// enumerated key, answered or not, and the key carries the asking instance's guid — so
// it is what lets one instance find its own verdict in a multi-instance session.
{
const std::string key = "rsbake_0123abcd";
BakeKeyOutcome landed;
landed.verdict = BakeScanVerdict::Land;
landed.landing = BakeLanding::Banked;
landed.proof = BakeWriteProof::Confirmed;
landed.detail = "added as a distinct capture";
const std::string ok = describeBakeKey(key, landed);
CHECK(ok.find(key) != std::string::npos);
CHECK(ok.find("landed into the bank") != std::string::npos);
CHECK(ok.find("The answer was written back") != std::string::npos);
CHECK(ok.back() == '\n');
// The outcome's own reason rides the SAME line: one self-contained line per key,
// rather than a keyed verdict and an unkeyed reason the reader has to pair up.
CHECK(ok.find("(added as a distinct capture)") != std::string::npos);
// The sentence names the OBSERVATION behind the flag (see BakeLanding) and stops
// there — the write was ISSUED into a saved project. It may not claim REAPER took
// the value, nor that the .rpp on disk already holds the entry.
CHECK(ok.find("issued its bank write into the saved project") != std::string::npos);
CHECK(ok.find("read back") == std::string::npos);
CHECK(ok.find(".rpp") == std::string::npos);
CHECK(ok.find("carries it") == std::string::npos);
// A landing whose answer write was REJECTED — the state the instrument reads as
// Unanswered. This line is the only place it is ever named.
BakeKeyOutcome lost = landed;
lost.proof = BakeWriteProof::Rejected;
const std::string dropped = describeBakeKey(key, lost);
CHECK(dropped.find("landed into the bank") != std::string::npos);
CHECK(dropped.find("could NOT be written back") != 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
// persisted, and one that threw mid-write, may not read as a clean success or as a
// clean refusal.
BakeKeyOutcome unpersisted = landed;
unpersisted.landing = BakeLanding::Unpersisted;
const std::string memoryOnly = describeBakeKey(key, unpersisted);
CHECK(memoryOnly.find("IN MEMORY ONLY") != std::string::npos);
CHECK(memoryOnly.find("persist did not report success") != std::string::npos);
CHECK(memoryOnly != ok);
// It may NOT claim what the project's saved state holds: the persist may never
// have run at all, and a dedup hit's target may have been in the project since
// long before this pass.
CHECK(memoryOnly.find("does not carry it") == std::string::npos);
BakeKeyOutcome partial = landed;
partial.landing = BakeLanding::Partial;
partial.detail = "the landing failed while writing: bad allocation";
const std::string half = describeBakeKey(key, partial);
CHECK(half.find("after it had begun writing") != std::string::npos);
CHECK(half.find("may have left a file") != std::string::npos);
CHECK(half.find("the landing was refused") == std::string::npos);
BakeKeyOutcome bankRefused;
bankRefused.verdict = BakeScanVerdict::Land;
bankRefused.landing = BakeLanding::Refused;
bankRefused.proof = BakeWriteProof::Confirmed;
bankRefused.detail = "the bank refused the new capture";
const std::string refusedLine = describeBakeKey(key, bankRefused);
CHECK(refusedLine.find("the landing was refused") != std::string::npos);
// The refusal's REASON is what the keyed line used to lack entirely.
CHECK(refusedLine.find("(the bank refused the new capture)") != std::string::npos);
BakeKeyOutcome wrongTab;
wrongTab.verdict = BakeScanVerdict::RefuseWrongProject;
wrongTab.proof = BakeWriteProof::Confirmed;
wrongTab.detail =
"this bake's project tab is not the one the extension has loaded -- focus that "
"tab and try again";
const std::string refused = describeBakeKey(key, wrongTab);
CHECK(refused.find("not the one the extension has loaded") != std::string::npos);
CHECK(refused.find("focus that tab and try again") != std::string::npos);
CHECK(refused.find("The answer was written back") != std::string::npos);
// A clear is a write too: it may be claimed only where it was read back.
BakeKeyOutcome cleared;
cleared.verdict = BakeScanVerdict::ClearStale;
cleared.proof = BakeWriteProof::Confirmed;
CHECK(describeBakeKey(key, cleared).find("cleared unanswered") != std::string::npos);
BakeKeyOutcome clearLost = cleared;
clearLost.proof = BakeWriteProof::Rejected;
const std::string stuck = describeBakeKey(key, clearLost);
CHECK(stuck.find("the clear did NOT take") != std::string::npos);
CHECK(stuck.find("next pass will see it again") != std::string::npos);
CHECK(stuck.find("it was cleared") == std::string::npos);
// Rejected also covers a clear that was never issued, so the line may not describe
// a read that did not happen.
CHECK(stuck.find("read back") == std::string::npos);
// And the clear's third state, same as an answer's: unchecked is not disproven.
BakeKeyOutcome clearUnchecked = cleared;
clearUnchecked.proof = BakeWriteProof::Unknown;
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;
notRequest.verdict = BakeScanVerdict::IgnoreNotARequest;
CHECK(describeBakeKey(key, notRequest).find("other than a pending request") !=
std::string::npos);
// Absent and Overflow both yield IgnoreUnreadable, but they are different faults to
// go fix, so the line keeps them apart where the verdict cannot.
BakeKeyOutcome empty;
empty.verdict = BakeScanVerdict::IgnoreUnreadable;
BakeKeyOutcome huge = empty;
huge.oversized = true;
const std::string emptyLine = describeBakeKey(key, empty);
const std::string hugeLine = describeBakeKey(key, huge);
CHECK(emptyLine.find("read back empty") != std::string::npos);
CHECK(hugeLine.find("too large to read back whole") != std::string::npos);
CHECK(emptyLine != hugeLine);
// Every line names its own key, so two instances' lines are never confusable.
CHECK(describeBakeKey("rsbake_ffff0000", landed).find("rsbake_ffff0000") !=
std::string::npos);
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.
//
// Both limbs are live in the shell, not just in this table: it assigns the landing
// inside a guarded scan and sets its `persisted` local only in the block after it, so a
// throw between the two reaches Unpersisted with a real landing behind it.
{
// 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 ---
// Judging this by SetProjExtState's return made `writeFailed` unproducible on the
// landing path and its sentence dead code (extStateWriteLanded owns why). The verdict is
// the read-back, which a store that drops the write does produce. The store is modelled
// here; the two shells that bind the verdict make these same two calls, to
// SetProjExtState and the grow-loop read.
{
struct FakeKeyStore {
std::map<std::string, std::string> values;
bool dropWrites = false;
void write(const std::string& k, const std::string& v) {
if (dropWrites) return; // REAPER rejected it
if (v.empty()) values.erase(k); // SetProjExtState("") deletes
else values[k] = v;
}
std::optional<std::string> read(const std::string& k) const {
const auto it = values.find(k);
return it == values.end() ? std::nullopt
: std::optional<std::string>(it->second);
}
};
const std::string key = "rsbake_0123abcd";
BakeRequest pending;
pending.instanceGuid = "0123abcd";
pending.generation = 1893456000;
BakeOutcome landedOk;
landedOk.status = BakeStatus::Ok;
landedOk.sampleId = "bake-1";
landedOk.message = "added as a distinct capture";
landedOk.generation = pending.generation;
const std::string answer = encodeBakeOutcome(landedOk);
// Rejected: the key still holds the request the instrument left there.
FakeKeyStore rejecting;
rejecting.values[key] = encodeBakeRequest(pending);
rejecting.dropWrites = true;
rejecting.write(key, answer);
CHECK(!extStateWriteLanded(answer, rejecting.read(key)));
// ...which is exactly what the ASKING end reads as a no-answer. The two classifiers
// agree about this one state, which is why the extension has to name it.
CHECK(classifyBakeAnswer(rejecting.read(key), pending).kind ==
BakeAnswerKind::Unanswered);
// The shell's write loop from that verdict through to both strings it prints.
BakeScanTally tally;
tally.tabsScanned = 1;
tally.keysFound = 1;
tally.activeTabKeys = 1;
tally.answered = 1;
BakeKeyOutcome report;
report.verdict = BakeScanVerdict::Land;
// The shell reaches Banked only through the upgrade — never by assignment.
report.landing = bakeLandingAfterPersist(BakeLanding::Unpersisted, true);
report.detail = landedOk.message;
report.proof = extStateWriteLanded(answer, rejecting.read(key))
? BakeWriteProof::Confirmed
: BakeWriteProof::Rejected;
if (report.proof == BakeWriteProof::Rejected) ++tally.writeFailed;
CHECK(tally.writeFailed == 1);
CHECK(describeBakeKey(key, report).find("could NOT be written back") !=
std::string::npos);
CHECK(describeBakeScan(tally).find("1 answer could not be written back") !=
std::string::npos);
// The same store ACCEPTING the write: the verdict flips, the tally stays at zero and
// the summary goes silent — so neither answer above is a constant.
FakeKeyStore accepting;
accepting.values[key] = encodeBakeRequest(pending);
accepting.write(key, answer);
CHECK(extStateWriteLanded(answer, accepting.read(key)));
CHECK(classifyBakeAnswer(accepting.read(key), pending).kind ==
BakeAnswerKind::Answered);
BakeScanTally clean = tally;
clean.writeFailed = 0;
CHECK(describeBakeScan(clean).empty());
// A CLEAR is proven the other way round: it lands as an ABSENT key, and a dropped
// clear leaves the stale request standing for the next pass to find.
FakeKeyStore clearing;
clearing.values[key] = encodeBakeRequest(pending);
clearing.write(key, "");
CHECK(extStateWriteLanded("", clearing.read(key)));
FakeKeyStore clearDropped;
clearDropped.values[key] = encodeBakeRequest(pending);
clearDropped.dropWrites = true;
clearDropped.write(key, "");
CHECK(!extStateWriteLanded("", clearDropped.read(key)));
// Byte equality is the claim the line makes: a truncated or foreign value under the
// key is not the answer we wrote, and an unreadable key proves nothing at all.
CHECK(!extStateWriteLanded(answer, std::optional<std::string>(
answer.substr(0, answer.size() - 1))));
CHECK(!extStateWriteLanded(answer, std::optional<std::string>(
encodeBakeRequest(pending))));
CHECK(!extStateWriteLanded(answer, std::nullopt));
CHECK(!extStateWriteLanded("", std::optional<std::string>("leftover")));
} }
// --- Every outcome bake_land actually emits survives the key round trip ------------- // --- Every outcome bake_land actually emits survives the key round trip -------------
@@ -518,9 +893,20 @@ int main() {
writeFailed.status = BakeStatus::Failed; writeFailed.status = BakeStatus::Failed;
writeFailed.message = "could not write the bake into the bank folder"; writeFailed.message = "could not write the bake into the bank folder";
// A landing the project would not persist is answered as a FAILURE, not as the Ok
// it was on its way to being: the entry exists in memory only, so an instance that
// adopted it would be pointing at something no reload will have.
BakeOutcome unpersisted = noProject;
unpersisted.status = BakeStatus::Failed;
unpersisted.message = kUnpersistedAnswer;
BakeOutcome partial = noProject;
partial.status = BakeStatus::Failed;
partial.message = "the landing failed while writing: bad allocation";
for (const BakeOutcome& emitted : for (const BakeOutcome& emitted :
{added, replaced, deduped, noProject, stagedMissing, noSource, indexRejected, {added, replaced, deduped, noProject, stagedMissing, noSource, indexRejected,
wrongProject, writeFailed}) { wrongProject, writeFailed, unpersisted, partial}) {
const auto back = decodeBakeOutcome(encodeBakeOutcome(emitted)); const auto back = decodeBakeOutcome(encodeBakeOutcome(emitted));
CHECK(back.has_value()); CHECK(back.has_value());
CHECK(back.has_value() && *back == emitted); CHECK(back.has_value() && *back == emitted);