diff --git a/src/core/wire/bake_wire.cpp b/src/core/wire/bake_wire.cpp index ed29cce..d24d0e1 100644 --- a/src/core/wire/bake_wire.cpp +++ b/src/core/wire/bake_wire.cpp @@ -127,6 +127,9 @@ std::optional decodeBakeOutcome(const std::string& wire) { BakeAnswer classifyBakeAnswer(const std::optional& raw, const BakeRequest& sent) { BakeAnswer answer; + // `raw` folds three distinct bridge outcomes into one — absent, explicitly + // cleared, and an unreadable/oversized read — because the bridge cannot label + // which occurred. A caller's message for `Cleared` must not claim more than that. if (!raw || raw->empty()) { answer.kind = BakeAnswerKind::Cleared; return answer; @@ -150,6 +153,11 @@ BakeAnswer classifyBakeAnswer(const std::optional& raw, return answer; } +const BakeOutcome* answeredOutcome(const BakeAnswer& answer) { + if (answer.kind != BakeAnswerKind::Answered || !answer.outcome) return nullptr; + return &*answer.outcome; +} + BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key, std::int64_t nowSec) { // Not a request: an outcome the writing instance has not collected yet, or a value @@ -161,11 +169,11 @@ BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanK if (age > kMaxRequestAgeSeconds || age < -kMaxRequestAgeSeconds) return BakeScanVerdict::ClearStale; - // Three tabs have to agree before anything may land: the tab the request was found in, - // the tab the session's book/ledger last loaded, and the tab a persist will write into. - // Landing on a disagreement writes one tab's bake into another's bank. - const bool landable = session.sessionHasLoadedProject && - session.loadedProjectIsActive && key.inLoadedProject; + // Two facts have to agree before anything may land: the loaded project is the one a + // persist would write into (loadedProjectIsActive), and this key's tab IS that loaded + // project (matchesLoadedProject — false by construction whenever no project is + // loaded, which is what folds the former three-way check into two). + const bool landable = session.loadedProjectIsActive && key.matchesLoadedProject; return landable ? BakeScanVerdict::Land : BakeScanVerdict::RefuseWrongProject; } diff --git a/src/core/wire/bake_wire.h b/src/core/wire/bake_wire.h index c03fd4b..8cd52eb 100644 --- a/src/core/wire/bake_wire.h +++ b/src/core/wire/bake_wire.h @@ -92,7 +92,8 @@ enum class BakeAnswerKind { ForeignOutcome, // a decodable outcome, but for another generation Unanswered, // this request, unchanged: nothing on the extension side read it ForeignRequest, // a request that is not ours — another instance shares this key - Cleared, // the key holds nothing: cleared without an answer + Cleared, // the key holds nothing — absent, explicitly cleared, or a read + // failure at the bridge; these are not distinguishable from here Undecodable, // neither record — a build whose wire this one does not read }; @@ -105,9 +106,20 @@ struct BakeAnswer { BakeAnswer classifyBakeAnswer(const std::optional& raw, const BakeRequest& sent); +// The outcome iff `answer.kind == Answered`; nullptr in every other state. The ONE place +// the Answered-implies-outcome-is-set contract is enforced, so a caller can fail closed +// on a state this switch does not (yet) name instead of dereferencing `answer.outcome` on +// the strength of switch exhaustiveness alone — exhaustiveness a future BakeAnswerKind +// enumerator (BakeAnswerKind is documented append-only, like its BakeStatus neighbor) +// would silently break with no compiler diagnostic (no -Wswitch/-Werror configured). +const BakeOutcome* answeredOutcome(const BakeAnswer& answer); + // The whole chain is a call and a return inside ONE editor tick, so a request older than // this has no reader left. Landing one would bank it for nobody and leave an outcome -// nobody collects in the .rpp forever. +// nobody collects in the .rpp forever. The bound is only meaningful because both ends +// read the same wall clock (std::time) AND the instrument stamps `generation` +// immediately before publishing the request — after staging the WAV, so that write +// never eats into the budget. inline constexpr std::int64_t kMaxRequestAgeSeconds = 30; // The extension's per-key verdict on one scanned `rsbake_*` key. @@ -118,19 +130,24 @@ enum class BakeScanVerdict { Ignore, // not a request (an uncollected outcome, or a wire we do not read) }; -// The session's side of the verdict — which tab the extension's book belongs to, and -// whether that tab is the one REAPER will persist into. +// The session's side of the verdict: whether the loaded project is the one REAPER will +// persist into. Whether a project is loaded at ALL is folded into +// BakeScanKey::matchesLoadedProject below rather than carried as a second flag here — a +// key can only ever match a project that is loaded, so a standalone "session has a +// project" flag on this side could disagree with the key's and describe a state the +// shell can never actually produce. struct BakeScanContext { - bool sessionHasLoadedProject = false; // the session has polled a project - bool loadedProjectIsActive = false; // that project is REAPER's active tab + bool loadedProjectIsActive = false; // the loaded project is REAPER's active tab }; -// The scanned key's side. Both flags are per-TAB, which is what makes a request found in -// a background tab decidable without any REAPER type crossing into this module. +// The scanned key's side — per-TAB, which is what makes a request found in a background +// tab decidable without any REAPER type crossing into this module. struct BakeScanKey { bool decoded = false; // the value decoded as a BakeRequest std::int64_t generation = 0; - bool inLoadedProject = false; + bool matchesLoadedProject = false; // this key's tab IS the session's loaded project + // — false whenever the session has no loaded + // project, by construction (see BakeScanContext) }; BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key, diff --git a/src/shell/capture/bake_land.cpp b/src/shell/capture/bake_land.cpp index 865356b..8107737 100644 --- a/src/shell/capture/bake_land.cpp +++ b/src/shell/capture/bake_land.cpp @@ -265,7 +265,10 @@ void RunResampleBake(ReaSamplerSession& session) { // land is told why rather than silently ignored. const void* loaded = session.loadedProject(); const void* active = EnumProjects(-1, nullptr, 0); - const wire::BakeScanContext scanContext{loaded != nullptr, loaded == active}; + // `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 + // matchesLoadedProject (below) falls out false regardless of this flag's value. + const wire::BakeScanContext scanContext{loaded == active}; const std::int64_t nowSec = static_cast(std::time(nullptr)); std::vector answers; diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index de1d92c..de46232 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -108,7 +108,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h - `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred). - `reasampler_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. -- `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_` 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. +- `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_` 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. - `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. - `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.)* diff --git a/src/shell/instrument/instrument_bake.cpp b/src/shell/instrument/instrument_bake.cpp index 3562c0a..d8817d0 100644 --- a/src/shell/instrument/instrument_bake.cpp +++ b/src/shell/instrument/instrument_bake.cpp @@ -154,14 +154,18 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { static_cast(audio.frameCount()), interleaved); const std::string instanceGuid = processor.usageInstanceGuid(); - const std::int64_t stamp = static_cast(std::time(nullptr)); + // Named for what it is used for here — the staged file's own name, nothing else. + // `request.generation` below takes its OWN, later timestamp: kMaxRequestAgeSeconds + // is a budget measured from the write, and re-using this one would silently spend it + // on the WAV write that happens in between. + const std::int64_t stageStamp = static_cast(std::time(nullptr)); // OUTSIDE the bank folder, always: the bank holds indexed captures only, and a stray // file there would read as a prune orphan. std::error_code ec; const fs::path stagedPath = fs::temp_directory_path(ec) / - ("reasampler_bake_" + instanceGuid + "_" + std::to_string(stamp) + ".wav"); + ("reasampler_bake_" + instanceGuid + "_" + std::to_string(stageStamp) + ".wav"); if (ec) return fail("no writable temp directory for the staged render"); const std::string staged = stagedPath.string(); StagedFileGuard stagedGuard(staged); @@ -175,7 +179,10 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { request.sourceDisplayName = sourceEntry->displayName; request.ownUsageKey = usageKeyFor(instanceGuid); request.rootNote = plan.note; - request.generation = stamp; + // Stamped here, immediately before the publish below — AFTER the WAV write above, + // which is what makes kMaxRequestAgeSeconds' budget (bake_wire.h) exclude staging + // time rather than eat into it. + request.generation = static_cast(std::time(nullptr)); const std::string key = bakeKeyFor(instanceGuid); RequestKeyGuard keyGuard(bridge, key); @@ -198,24 +205,38 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) { case wire::BakeAnswerKind::Unanswered: return fail( "the ReaSampler extension did not run the bake landing -- its action id " - "resolved but nothing read the request. Most likely the installed " - "extension is older than this plugin: reinstall it and restart REAPER"); + "resolved but nothing read the request. Possible causes: the extension is " + "older than this plugin and does not know this action; REAPER deferred " + "running the action past this call returning (unverified -- see " + "reaper_bridge.h); the extension ran but could not read its own request " + "key on this pass; or the action name resolved to an id no currently " + "loaded extension actually handles. Reinstalling the extension and " + "restarting REAPER is worth trying, but is not the only possible fix"); case wire::BakeAnswerKind::Undecodable: return fail( "the extension answered in a format this plugin does not read -- the " "extension and ReaSampler 9000 are from different builds"); case wire::BakeAnswerKind::Cleared: return fail( - "the extension discarded the bake request as stale before answering it"); + "the bake key came back empty -- either the request was cleared before " + "an answer was written, or this build could not read whatever was there"); case wire::BakeAnswerKind::ForeignRequest: return fail( - "another ReaSampler 9000 instance is baking under this instance's key -- " - "the two were copied from one another; reload this one and retry"); + "the bake key held a different pending request instead of an answer -- " + "unexpected given the bake's single-threaded call flow; if this recurs, " + "note the exact steps and file it"); case wire::BakeAnswerKind::ForeignOutcome: return fail("the extension answered a different bake request"); } - // Set by construction on Answered (bake_wire.h states the invariant at BakeAnswer). - const BakeOutcome& outcome = *answer.outcome; + // 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 + // would otherwise fall through to a raw `*answer.outcome` deref with nothing set. + const BakeOutcome* outcomePtr = wire::answeredOutcome(answer); + if (!outcomePtr) + return fail( + "the extension's answer was not one this build recognizes -- the extension " + "and ReaSampler 9000 may be from different builds"); + const BakeOutcome& outcome = *outcomePtr; if (outcome.status != BakeStatus::Ok) return fail(outcome.message.empty() ? std::string("the bake was refused") : outcome.message); diff --git a/tests/test_bake_wire.cpp b/tests/test_bake_wire.cpp index 3e2bf0d..4b8ad12 100644 --- a/tests/test_bake_wire.cpp +++ b/tests/test_bake_wire.cpp @@ -259,9 +259,16 @@ int main() { CHECK(classifyBakeAnswer(std::optional(encodeBakeRequest(sibling)), sent) .kind == BakeAnswerKind::ForeignRequest); - CHECK(classifyBakeAnswer(std::nullopt, sent).kind == BakeAnswerKind::Cleared); - CHECK(classifyBakeAnswer(std::optional(""), sent).kind == - BakeAnswerKind::Cleared); + // Cleared folds two genuinely different bridge outcomes -- key absent, and key + // present but empty -- into ONE kind, because the bridge cannot label which + // occurred. Both must land on the identical kind: a caller's message may claim + // no more than what the fold actually preserves. + const BakeAnswer clearedFromAbsent = classifyBakeAnswer(std::nullopt, sent); + const BakeAnswer clearedFromEmpty = + classifyBakeAnswer(std::optional(""), sent); + CHECK(clearedFromAbsent.kind == BakeAnswerKind::Cleared); + CHECK(clearedFromEmpty.kind == BakeAnswerKind::Cleared); + CHECK(clearedFromAbsent.kind == clearedFromEmpty.kind); CHECK(classifyBakeAnswer(std::optional("rsbakeout9 whatever"), sent) .kind == BakeAnswerKind::Undecodable); @@ -273,12 +280,58 @@ int main() { .outcome.has_value()); } + // --- answeredOutcome: the guarded read, not switch exhaustiveness alone ------------- + { + BakeRequest sent; + sent.instanceGuid = "abcd"; + sent.generation = 42; + + BakeOutcome landed; + landed.status = BakeStatus::Ok; + landed.sampleId = "bake-1"; + landed.generation = sent.generation; + + // The one state that may yield a non-null pointer, and it points at the SAME + // outcome classifyBakeAnswer decoded. + const BakeAnswer answered = + classifyBakeAnswer(std::optional(encodeBakeOutcome(landed)), sent); + const BakeOutcome* ptr = answeredOutcome(answered); + CHECK(ptr != nullptr); + CHECK(ptr->sampleId == "bake-1"); + + // Every other real classification nulls out. + CHECK(answeredOutcome(classifyBakeAnswer(std::nullopt, sent)) == nullptr); + CHECK(answeredOutcome(classifyBakeAnswer( + std::optional(encodeBakeRequest(sent)), sent)) == nullptr); + CHECK(answeredOutcome(classifyBakeAnswer(std::optional("garbage"), sent)) + == nullptr); + BakeOutcome foreign = landed; + foreign.generation = sent.generation + 1; + CHECK(answeredOutcome(classifyBakeAnswer( + std::optional(encodeBakeOutcome(foreign)), sent)) == nullptr); + + // The defensive arm: a hand-built Answered with no outcome set (what a future + // BakeAnswerKind enumerator falling through an un-updated switch would look like + // to this accessor) fails closed rather than being dereferenced. + BakeAnswer malformed; + malformed.kind = BakeAnswerKind::Answered; + CHECK(!malformed.outcome.has_value()); + CHECK(answeredOutcome(malformed) == nullptr); + } + + // --- kMaxRequestAgeSeconds: a regression pin on the bound itself -------------------- + // The bound's meaning depends on the instrument stamping `generation` immediately + // before publishing (AFTER staging the WAV) -- a shell-side ordering fix this pure + // suite cannot observe directly. Pinning the literal at least catches a silent + // widen/narrow of the budget itself. + CHECK(kMaxRequestAgeSeconds == 30); + // --- The extension's per-key verdict over the open tabs ----------------------------- { const std::int64_t now = 1893456000; // The session has polled a project and that project is REAPER's active tab — the // steady state a project opened from disk reaches on the next timer tick. - const BakeScanContext loadedAndActive{true, true}; + const BakeScanContext loadedAndActive{true}; BakeScanKey own{true, now, true}; CHECK(classifyBakeScan(loadedAndActive, own, now) == BakeScanVerdict::Land); @@ -292,11 +345,13 @@ int main() { // The loaded tab is no longer the active one, so a persist would write elsewhere: // even the loaded tab's own request is refused rather than half-landed. - CHECK(classifyBakeScan(BakeScanContext{true, false}, own, now) == + CHECK(classifyBakeScan(BakeScanContext{false}, own, now) == BakeScanVerdict::RefuseWrongProject); - // The window between opening a project from disk and the first poll: no book is - // loaded yet, so nothing may land. - CHECK(classifyBakeScan(BakeScanContext{false, false}, own, now) == + // The window before any project is loaded: matchesLoadedProject cannot be true for + // ANY key here — a key can only match a project that is loaded — so this is the + // shell-reachable stand-in for "no book yet", not `own` (which the shell could + // never actually pair with an unloaded session). + CHECK(classifyBakeScan(BakeScanContext{false}, BakeScanKey{true, now, false}, now) == BakeScanVerdict::RefuseWrongProject); // Stale in EITHER direction (a clock that moved backwards counts too). @@ -318,7 +373,7 @@ int main() { BakeScanKey notARequest{false, 0, true}; CHECK(classifyBakeScan(loadedAndActive, notARequest, now) == BakeScanVerdict::Ignore); - CHECK(classifyBakeScan(BakeScanContext{false, false}, notARequest, now) == + CHECK(classifyBakeScan(BakeScanContext{false}, notARequest, now) == BakeScanVerdict::Ignore); }