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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user