Merge bake-answer diagnosis: five named failure modes and a scan report that says what the landing saw

This commit is contained in:
2026-08-02 08:06:45 -04:00
8 changed files with 649 additions and 57 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 / 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.
- `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.
+91
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"
@@ -32,6 +34,10 @@ BakeStatus statusFromWire(int raw) {
return BakeStatus::Failed;
}
std::string countOf(int n, const char* noun) {
return std::to_string(n) + " " + noun + (n == 1 ? "" : "s");
}
} // namespace
std::string bakeActionLookupName() {
@@ -122,4 +128,89 @@ std::optional<BakeOutcome> decodeBakeOutcome(const std::string& wire) {
return outcome;
}
BakeAnswer classifyBakeAnswer(const std::optional<std::string>& 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;
}
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;
}
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) {
// Listed by the enumerator but not returned whole by the reader. Distinct from the
// next case even though both leave the key alone: this one is the extension failing
// to read a request that may well be there, and it is invisible from the other end.
if (!key.readable) return BakeScanVerdict::IgnoreUnreadable;
// 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::IgnoreNotARequest;
// 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;
// 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;
}
std::string describeBakeScan(const BakeScanTally& t) {
if (t.answered > 0) return {};
std::string s = "ReaSampler resample: the landing action ran and scanned " +
countOf(t.tabsScanned, "project tab") + ", and answered nothing. ";
if (t.keysFound == 0) {
s += "No pending bake request was visible to it at all -- if ReaSampler 9000 "
"reported publishing one, the plugin and the extension are not reading the "
"same project's ext state.";
return s + "\n";
}
s += "It found " + countOf(t.keysFound, "pending request key") + ", " +
std::to_string(t.activeTabKeys) + " of them in the active tab";
if (t.unreadable > 0)
s += "; " + std::to_string(t.unreadable) + " could not be read back";
if (t.notARequest > 0)
s += "; " + std::to_string(t.notARequest) + " held something other than a request";
if (t.staleCleared > 0)
s += "; " + std::to_string(t.staleCleared) +
" were past the age bound and were cleared unanswered";
s += ".";
if (t.activeTabKeys == 0)
s += " The tab this bake was fired against held none of them.";
return s + "\n";
}
} // namespace reasampler::wire
+98
View File
@@ -79,4 +79,102 @@ 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 — 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
};
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 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. 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.
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
IgnoreUnreadable, // the enumerator listed it, the reader could not return it whole
IgnoreNotARequest, // read whole, but an uncollected outcome or a wire we do not read
};
// 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 loadedProjectIsActive = false; // the loaded project is REAPER's active tab
};
// 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 readable = false; // the enumerated key's value came back WHOLE
bool decoded = false; // that value decoded as a BakeRequest (implies readable)
std::int64_t generation = 0;
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,
std::int64_t nowSec);
// What ONE scan pass actually saw, accumulated by the shell as it applies the verdicts
// above. Counts only, so the report below is provable without a DAW. It exists because
// every non-answering verdict leaves the asking instance with the same evidence — its own
// request, untouched — and only these counts say which of them happened.
struct BakeScanTally {
int tabsScanned = 0;
int keysFound = 0; // `rsbake_*` keys enumerated across every open tab
int activeTabKeys = 0; // of those, in the tab the action was fired against
int unreadable = 0; // IgnoreUnreadable
int notARequest = 0; // IgnoreNotARequest
int staleCleared = 0; // ClearStale
int answered = 0; // an outcome was written back (a landing OR a refusal)
int landed = 0; // of `answered`, the ones that reached the bank
};
// The console sentence for a pass that answered NOTHING — the one state in which the
// asking instance reports a no-answer and has nothing to go on. Empty string when
// `answered > 0`, so the caller prints unconditionally and stays quiet on a pass that
// spoke for itself. The line's absence is itself evidence: no line means the landing
// action never ran.
std::string describeBakeScan(const BakeScanTally& tally);
} // namespace reasampler::wire