bake: name the five ways the extension can fail to answer, and move the landing verdict into a pure, tab-provable classifier

A no-answer stays a failure; it now says whether the extension never ran the landing, answered a stale generation, spoke a wire this build cannot read, cleared the request, or refused it.
This commit is contained in:
2026-08-02 06:38:04 -04:00
parent 6e937b9c61
commit 962ab64ef0
8 changed files with 373 additions and 48 deletions
+1 -1
View File
@@ -80,7 +80,7 @@ This directory owns two cross-artifact contracts specifically:
- `wire` (`core/wire`) — the ONE length-prefixed ext-state wire codec (Q-W1): `putField`/`parseUnsignedDecimal` + the bounds-checked `Cursor` (`field`/`fieldInt`/`fieldInt64`/`fieldSizeT`/`fieldDouble`), replacing four near-identical copies (`provenance` / `assignment_request` / `sample_usage` / `bank_sync`). `core/wire/bytes.h` is the sibling little-endian byte codec (`putLE`, `ByteReader`, `doubleToBits`/`bitsToDouble`) that `component_state_io` is the biggest consumer of. `core/wire/ext_state_read.h` owns the `GetProjExtState` grow-loop retry policy (Absent/Complete/Overflow) shared by `persist`, `usage_scan`, and `reaper_bridge`. `core/wire/reasampler_uid.h` (the FOREVER-FROZEN VST3 class-UID macros) also lives in this directory.
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
- `bake_wire` — the resample bake's request/outcome pair on ONE per-instance key (`rsbake_<guid>`): the instrument writes a `BakeRequest`, invokes the extension's action synchronously, and reads the extension's `BakeOutcome` back over the same key inside that one call. Not a handshake — a call and a return, and it must not grow a claim protocol. Also the ONE home of the bake action's command-id suffix and of the leading underscore `NamedCommandLookup` needs but `rec->Register("command_id", …)` does not, so both artifacts name one action. `BakeStatus` values are WIRE INTEGERS: never renumber, only append, and an unrecognized value decodes as `Failed` rather than as the numeric default `Ok`.
- `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 / Ignore verdict over every open tab, stated without a REAPER type so the multi-tab matrix is unit-provable).
- `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.
+47
View File
@@ -2,6 +2,8 @@
#include "core/wire/bake_wire.h"
#include <utility>
#include "core/version/app_version.h"
#include "core/wire/wire.h"
@@ -122,4 +124,49 @@ std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire) {
return outcome;
}
BakeAnswer classifyBakeAnswer(const std::optional<std::string>& raw,
const BakeRequest& sent) {
BakeAnswer answer;
if (!raw || raw->empty()) {
answer.kind = BakeAnswerKind::Cleared;
return answer;
}
if (std::optional<BakeOutcome> outcome = decodeBakeOutcome(*raw)) {
answer.kind = outcome->generation == sent.generation
? BakeAnswerKind::Answered
: BakeAnswerKind::ForeignOutcome;
answer.outcome = std::move(outcome);
return answer;
}
// Still a request: whether it is OURS is what separates "nothing read this key" from
// "another instance overwrote it" — a persisted instanceGuid is copyable, so two
// instances CAN name one key.
if (const std::optional<BakeRequest> req = decodeBakeRequest(*raw)) {
answer.kind =
*req == sent ? BakeAnswerKind::Unanswered : BakeAnswerKind::ForeignRequest;
return answer;
}
answer.kind = BakeAnswerKind::Undecodable;
return answer;
}
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
// from a build we do not read. The writer owns clearing its own key.
if (!key.decoded) return BakeScanVerdict::Ignore;
// Either direction, so a clock moved backwards is caught too.
const std::int64_t age = nowSec - key.generation;
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;
return landable ? BakeScanVerdict::Land : BakeScanVerdict::RefuseWrongProject;
}
} // namespace reasampler::wire
+57
View File
@@ -79,4 +79,61 @@ std::string encodeBakeOutcome(const BakeOutcome& outcome);
std::optional<BakeRequest> decodeBakeRequest(const std::string& wire);
std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire);
// --- Reading the shared key, from either end -----------------------------------------
//
// One key carries both records, so what it holds after the action returned is the ONLY
// 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
// distinction that matters: `Unanswered` is the extension never having read the key at
// all, which is a DIFFERENT fault from every refusal — a refusal is an outcome.
enum class BakeAnswerKind {
Answered, // a decodable outcome echoing this request's generation
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
Undecodable, // neither record — a build whose wire this one does not read
};
struct BakeAnswer {
BakeAnswerKind kind = BakeAnswerKind::Cleared;
std::optional<BakeOutcome> outcome; // set iff Answered or ForeignOutcome
};
// `raw` is the key's value after the invocation (nullopt = absent/empty).
BakeAnswer classifyBakeAnswer(const std::optional<std::string>& raw,
const BakeRequest& sent);
// 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.
inline constexpr std::int64_t kMaxRequestAgeSeconds = 30;
// The extension's per-key verdict on one scanned `rsbake_*` key.
enum class BakeScanVerdict {
Land, // land it into the loaded project's bank
RefuseWrongProject, // answer WrongProject — the book in memory belongs to another tab
ClearStale, // no reader left: clear the key, never answer it
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.
struct BakeScanContext {
bool sessionHasLoadedProject = false; // the session has polled a project
bool loadedProjectIsActive = false; // that 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.
struct BakeScanKey {
bool decoded = false; // the value decoded as a BakeRequest
std::int64_t generation = 0;
bool inLoadedProject = false;
};
BakeScanVerdict classifyBakeScan(const BakeScanContext& session, const BakeScanKey& key,
std::int64_t nowSec);
} // namespace reasampler::wire
+1 -1
View File
@@ -56,7 +56,7 @@ 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_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).
- `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. 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: 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. 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.
- `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.
- `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).
+18 -27
View File
@@ -6,7 +6,6 @@
#include "shell/capture/bake_land.h"
#include <cstdint>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
@@ -53,13 +52,6 @@ using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
// The whole chain is a call and a return inside ONE editor tick, so a request older than
// this has no reader left: it is a crash leftover, and it is CLEARED rather than landed.
// Without the guard a stranded request would be banked on the next unrelated instance's
// click, and the outcome written back to a key nobody will collect would persist into the
// .rpp forever.
constexpr std::int64_t kMaxRequestAgeSeconds = 30;
BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generation) {
BakeOutcome out;
out.status = status;
@@ -268,15 +260,12 @@ struct Answer {
} // namespace
void RunResampleBake(ReaSamplerSession& session) {
// Three projects have to agree before anything may land: the tab the request was found
// in, the tab whose book/ledger poll() last loaded, and the tab saveToActiveProject
// will persist into. Land on a disagreement and one tab's bake is written into
// another's bank. Whether REAPER makes Main_OnCommandEx's `proj` current for the
// action's duration is unverified in the DAW; this holds either way, and a request it
// cannot land is told why rather than silently ignored.
// 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* active = EnumProjects(-1, nullptr, 0);
const bool sessionUsable = loaded != nullptr && loaded == active;
const wire::BakeScanContext scanContext{loaded != nullptr, loaded == active};
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
std::vector<Answer> answers;
@@ -285,21 +274,23 @@ void RunResampleBake(ReaSamplerSession& session) {
for (const OpenProject& open : openProjects()) {
for (const std::string& key : pendingBakeKeys(open.proj)) {
const std::optional<std::string> raw = readKey(open.proj, key);
if (!raw) continue;
const std::optional<BakeRequest> request = wire::decodeBakeRequest(*raw);
// Not a request: an outcome this instance has not yet collected, or a value from
// a build we do not read. Leave it — the writing instance owns clearing its key.
if (!request) continue;
const std::optional<BakeRequest> request =
raw ? wire::decodeBakeRequest(*raw) : std::nullopt;
const wire::BakeScanKey scanKey{
request.has_value(), request ? request->generation : 0,
static_cast<const void*>(open.proj) == loaded};
const wire::BakeScanVerdict verdict =
wire::classifyBakeScan(scanContext, scanKey, nowSec);
// Stale (either direction, so a clock moved backwards is caught too): clear the
// key, never answer it. The instance that could read an answer is gone.
if (std::llabs(nowSec - request->generation) > kMaxRequestAgeSeconds) {
// Leave it — the writing instance owns clearing its own key.
if (verdict == wire::BakeScanVerdict::Ignore) continue;
if (verdict == wire::BakeScanVerdict::ClearStale) {
answers.push_back(Answer{open.proj, key, std::string{}, std::string{}});
continue;
}
BakeOutcome outcome;
if (!sessionUsable || static_cast<const void*>(open.proj) != loaded) {
if (verdict == wire::BakeScanVerdict::RefuseWrongProject) {
outcome = refuse(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension has "
"loaded -- focus that tab and try again",
@@ -310,9 +301,9 @@ void RunResampleBake(ReaSamplerSession& session) {
}
// 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"
// (see the three-projects comment above) — only that tab's own refusal is fresh
// feedback to a user who just clicked bake; every other one is a rescan repeat.
// 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.
const bool ownRequest = static_cast<const void*>(open.proj) == active;
std::string console;
if (outcome.status != BakeStatus::Ok &&
+1 -1
View File
@@ -108,7 +108,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_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_<guid>` request, invoke the extension's landing action SYNCHRONOUSLY, read the outcome back over the same key, then adopt + reset in one act. 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_<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.
- `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.)*
+38 -17
View File
@@ -187,24 +187,45 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
if (!bridge.invokeExtensionAction(wire::bakeActionLookupName()))
return fail("the ReaSampler extension's bake action is not registered");
const std::optional<std::string> raw = bridge.readReasamplerExtState(key);
const std::optional<BakeOutcome> outcome =
raw ? wire::decodeBakeOutcome(*raw) : std::nullopt;
// No outcome at all means the action never reached our request — an older extension
// that registers the id but does not read this key, or an invocation REAPER deferred.
if (!outcome) return fail("the extension did not answer the bake request");
if (outcome->generation != request.generation)
return fail("the extension answered a different bake request");
if (outcome->status != BakeStatus::Ok)
return fail(outcome->message.empty() ? std::string("the bake was refused")
: outcome->message);
// 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
// what made a stale install indistinguishable from a refusal.
const wire::BakeAnswer answer =
wire::classifyBakeAnswer(bridge.readReasamplerExtState(key), request);
switch (answer.kind) {
case wire::BakeAnswerKind::Answered:
break;
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");
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");
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");
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;
if (outcome.status != BakeStatus::Ok)
return fail(outcome.message.empty() ? std::string("the bake was refused")
: outcome.message);
SampleRefEntry entry;
entry.sampleId = outcome->sampleId;
entry.displayName = outcome->displayName;
entry.ref.relativePath = outcome->relativePath;
entry.ref.rootNote = outcome->rootNote;
entry.ref.channelCount = outcome->channelCount;
entry.sampleId = outcome.sampleId;
entry.displayName = outcome.displayName;
entry.ref.relativePath = outcome.relativePath;
entry.ref.rootNote = outcome.rootNote;
entry.ref.channelCount = outcome.channelCount;
// No loop: the loop points shaped the render and are meaningless against the new file
// (bake_reset owns that rule for the parameter set; this is its bank-intrinsic peer).
@@ -213,7 +234,7 @@ BakeChainResult runBake(ReaSamplerProcessor& processor) {
// The extension's own wording, which distinguishes the three landings (replaced, added,
// and pointed at an identical existing entry) more precisely than this side can.
return BakeChainResult{true, "resampled -- " + outcome->message};
return BakeChainResult{true, "resampled -- " + outcome.message};
}
} // namespace reasampler::vst
+210 -1
View File
@@ -6,13 +6,16 @@
// break a mixed-version pair); request + outcome round-trips including bytes that would
// 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
// degrading to Failed rather than to Ok; and the action lookup name's leading underscore.
// 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.
#include "../src/core/wire/bake_wire.h"
#include "../src/core/version/app_version.h"
#include <cstdint>
#include <cstdio>
#include <optional>
#include <string>
using namespace reasampler::wire;
@@ -183,6 +186,212 @@ int main() {
CHECK(lookup.compare(lookup.size() - suffixLen, suffixLen, kBakeActionSuffix) == 0);
}
// --- The action id and the ext-state namespace fork on the SAME channel bit ---------
// Both frozen families are spelled out, so this holds whichever channel the test binary
// was built for. Consequence for a cross-channel pair (stable VST + beta extension or
// the reverse): NamedCommandLookup resolves nothing, bakeAvailable paints the affordance
// Disabled, and the click cannot reach the no-answer path at all.
{
const std::string suffix = kBakeActionSuffix;
const std::string stableLookup = "_CEREBELLUM_REASAMPLER_" + suffix;
const std::string betaLookup = "_CEREBELLUM_REASAMPLER_BETA_" + suffix;
const bool beta = reasampler::version::isBeta();
CHECK(stableLookup != betaLookup);
CHECK(bakeActionLookupName() == (beta ? betaLookup : stableLookup));
CHECK(bakeActionLookupName() != (beta ? stableLookup : betaLookup));
CHECK(reasampler::version::extStateNamespace() ==
(beta ? "reasampler_beta" : "reasampler"));
}
// --- What the instrument finds under its own key, after the action returned ---------
{
BakeRequest sent;
sent.instanceGuid = "0123abcd";
sent.stagedFilePath = "C:/Temp/reasampler_bake_0123abcd_1893456000.wav";
sent.sourceSampleId = "cap-1";
sent.sourceRelativePath = "reasampler_bank/kick.wav";
sent.sourceDisplayName = "Kick";
sent.ownUsageKey = "rsusage_0123abcd";
sent.rootNote = 36;
sent.generation = 1893456000;
BakeOutcome answered;
answered.status = BakeStatus::Ok;
answered.sampleId = "bake-1";
answered.message = "added as a distinct capture";
answered.generation = sent.generation;
const BakeAnswer ok =
classifyBakeAnswer(std::optional<std::string>(encodeBakeOutcome(answered)), sent);
CHECK(ok.kind == BakeAnswerKind::Answered);
CHECK(ok.outcome.has_value() && ok.outcome->sampleId == "bake-1");
// A refusal is an ANSWER — it must never fold into a no-answer kind, or the user is
// sent to reinstall a binary that in fact answered them.
BakeOutcome refused;
refused.status = BakeStatus::WrongProject;
refused.message = "this bake's project tab is not the one the extension has loaded";
refused.generation = sent.generation;
const BakeAnswer refusal =
classifyBakeAnswer(std::optional<std::string>(encodeBakeOutcome(refused)), sent);
CHECK(refusal.kind == BakeAnswerKind::Answered);
CHECK(refusal.outcome.has_value() &&
refusal.outcome->status == BakeStatus::WrongProject);
BakeOutcome older = answered;
older.generation = sent.generation - 1;
const BakeAnswer foreignOut =
classifyBakeAnswer(std::optional<std::string>(encodeBakeOutcome(older)), sent);
CHECK(foreignOut.kind == BakeAnswerKind::ForeignOutcome);
CHECK(foreignOut.outcome.has_value());
// Nothing on the extension side read the key: the request is still sitting there
// byte-for-byte. THE diagnostic that separates a stale/absent landing from a refusal.
CHECK(classifyBakeAnswer(std::optional<std::string>(encodeBakeRequest(sent)), sent)
.kind == BakeAnswerKind::Unanswered);
// A request that is not ours: two instances copied from one another share a
// persisted instanceGuid, so they name one key.
BakeRequest sibling = sent;
sibling.sourceSampleId = "cap-2";
sibling.generation = sent.generation + 1;
CHECK(classifyBakeAnswer(std::optional<std::string>(encodeBakeRequest(sibling)), sent)
.kind == BakeAnswerKind::ForeignRequest);
CHECK(classifyBakeAnswer(std::nullopt, sent).kind == BakeAnswerKind::Cleared);
CHECK(classifyBakeAnswer(std::optional<std::string>(""), sent).kind ==
BakeAnswerKind::Cleared);
CHECK(classifyBakeAnswer(std::optional<std::string>("rsbakeout9 whatever"), sent)
.kind == BakeAnswerKind::Undecodable);
// No non-Answered kind may carry an outcome the caller could read as a landing.
CHECK(!classifyBakeAnswer(std::nullopt, sent).outcome.has_value());
CHECK(!classifyBakeAnswer(std::optional<std::string>(encodeBakeRequest(sent)), sent)
.outcome.has_value());
CHECK(!classifyBakeAnswer(std::optional<std::string>("garbage"), sent)
.outcome.has_value());
}
// --- 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};
BakeScanKey own{true, now, true};
CHECK(classifyBakeScan(loadedAndActive, own, now) == BakeScanVerdict::Land);
// A request found in a tab the extension has NOT loaded — the multi-tab case. It is
// REFUSED, which is an answer the asking instance can read; it is never landed into
// the loaded tab's bank, and never dropped silently.
BakeScanKey otherTab{true, now, false};
CHECK(classifyBakeScan(loadedAndActive, otherTab, now) ==
BakeScanVerdict::RefuseWrongProject);
// 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) ==
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) ==
BakeScanVerdict::RefuseWrongProject);
// Stale in EITHER direction (a clock that moved backwards counts too).
BakeScanKey old{true, now - kMaxRequestAgeSeconds - 1, true};
BakeScanKey future{true, now + kMaxRequestAgeSeconds + 1, true};
CHECK(classifyBakeScan(loadedAndActive, old, now) == BakeScanVerdict::ClearStale);
CHECK(classifyBakeScan(loadedAndActive, future, now) == BakeScanVerdict::ClearStale);
// Exactly at the bound is still landable — the ceiling is inclusive.
BakeScanKey atBound{true, now - kMaxRequestAgeSeconds, true};
CHECK(classifyBakeScan(loadedAndActive, atBound, now) == BakeScanVerdict::Land);
// Staleness outranks the project check: a request nobody can read is cleared, not
// answered, wherever it sits.
BakeScanKey oldElsewhere{true, now - kMaxRequestAgeSeconds - 1, false};
CHECK(classifyBakeScan(loadedAndActive, oldElsewhere, now) ==
BakeScanVerdict::ClearStale);
// A value that is not a request (an outcome the writer has not collected) is left
// alone — answering it would clobber an answer in flight.
BakeScanKey notARequest{false, 0, true};
CHECK(classifyBakeScan(loadedAndActive, notARequest, now) ==
BakeScanVerdict::Ignore);
CHECK(classifyBakeScan(BakeScanContext{false, false}, notARequest, now) ==
BakeScanVerdict::Ignore);
}
// --- Every outcome bake_land actually emits survives the key round trip -------------
// The landing writes these and the instrument reads them back; a field the encoder and
// the decoder disagreed about would strand exactly the bake that produced it.
{
BakeOutcome added;
added.status = BakeStatus::Ok;
added.sampleId = "bake-1893456000-a1b2c3d4-kick_1893456000.wav";
added.relativePath = "reasampler_bank/kick_1893456000-a1b2c3d4.wav";
added.displayName = "Kick 2";
added.rootNote = 36;
added.channelCount = 2;
added.replaced = false;
added.message = "added as a distinct capture";
added.generation = 1893456000;
BakeOutcome replaced = added;
replaced.replaced = true;
replaced.displayName = "Kick";
replaced.message = "replaced the bank entry";
BakeOutcome deduped = added;
deduped.message = "identical to an existing capture -- pointed at it";
BakeOutcome noProject;
noProject.status = BakeStatus::NoProject;
noProject.message = "no saved project, so the bank has no location";
noProject.generation = added.generation;
BakeOutcome stagedMissing = noProject;
stagedMissing.status = BakeStatus::StagedMissing;
stagedMissing.message = "the staged render is not a usable WAV";
BakeOutcome noSource = noProject;
noSource.status = BakeStatus::NoSource;
noSource.message = "the resampled capture is not in any bank";
BakeOutcome indexRejected = noProject;
indexRejected.status = BakeStatus::IndexRejected;
indexRejected.message = "the bank refused the new capture";
BakeOutcome wrongProject = noProject;
wrongProject.status = BakeStatus::WrongProject;
wrongProject.message =
"this bake's project tab is not the one the extension has "
"loaded -- focus that tab and try again";
BakeOutcome writeFailed = noProject;
writeFailed.status = BakeStatus::Failed;
writeFailed.message = "could not write the bake into the bank folder";
for (const BakeOutcome& emitted :
{added, replaced, deduped, noProject, stagedMissing, noSource, indexRejected,
wrongProject, writeFailed}) {
const auto back = decodeBakeOutcome(encodeBakeOutcome(emitted));
CHECK(back.has_value());
CHECK(back.has_value() && *back == emitted);
// Field for field as well: operator== is hand-written, so a field missing from
// BOTH the codec and the comparison would pass the aggregate check above.
CHECK(back.has_value() && back->status == emitted.status &&
back->sampleId == emitted.sampleId &&
back->relativePath == emitted.relativePath &&
back->displayName == emitted.displayName &&
back->rootNote == emitted.rootNote &&
back->channelCount == emitted.channelCount &&
back->replaced == emitted.replaced &&
back->message == emitted.message &&
back->generation == emitted.generation);
}
}
if (g_fail == 0) std::printf("bake_wire: all tests passed\n");
return g_fail ? 1 : 0;
}