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