From 8dac5b4a543aff3c385f0ccd17dca86910340f0a Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 29 Jul 2026 20:49:28 -0400 Subject: [PATCH] Cut core/wire and shell/persist comment bloat ~46% (comments only, zero code change) --- src/core/wire/assignment_request.cpp | 3 - src/core/wire/assignment_request.h | 82 ++----- src/core/wire/bytes.h | 30 +-- src/core/wire/ext_state_read.h | 33 ++- src/core/wire/instrument_drop.cpp | 33 +-- src/core/wire/instrument_drop.h | 137 ++++------- src/core/wire/reasampler_uid.h | 31 +-- src/core/wire/sample_usage.cpp | 78 ++----- src/core/wire/sample_usage.h | 277 ++++++++-------------- src/core/wire/wire.cpp | 6 +- src/core/wire/wire.h | 53 ++--- src/shell/persist/ext_state_io.cpp | 219 ++++++------------ src/shell/persist/ext_state_io.h | 67 ++---- src/shell/persist/persist_internal.h | 18 +- src/shell/persist/prune_fs.cpp | 217 +++++++---------- src/shell/persist/session.cpp | 137 ++++------- src/shell/persist/session.h | 332 ++++++++------------------- src/shell/persist/usage_scan.cpp | 111 ++++----- src/shell/persist/usage_scan.h | 60 ++--- 19 files changed, 638 insertions(+), 1286 deletions(-) diff --git a/src/core/wire/assignment_request.cpp b/src/core/wire/assignment_request.cpp index ab05c53..8ac9d27 100644 --- a/src/core/wire/assignment_request.cpp +++ b/src/core/wire/assignment_request.cpp @@ -10,9 +10,6 @@ namespace { constexpr const char* kMagic = "rsassign1"; -// The shared core/wire codec (Q-W1, T2-01b) — the same field grammar + hardening -// this file previously carried as its own Cursor copy. "never UB, never a -// partial value" is upheld in the codec. using wire::putField; using Cursor = wire::Cursor; diff --git a/src/core/wire/assignment_request.h b/src/core/wire/assignment_request.h index 12e469a..90f5dec 100644 --- a/src/core/wire/assignment_request.h +++ b/src/core/wire/assignment_request.h @@ -1,41 +1,18 @@ #pragma once -// assignment_request — the pure core of the S8 ingest assignment-request seam. +// assignment_request — pure core of the ingest assignment-request seam. No +// REAPER/SWELL/VST3/vendor includes; unit-tested outside the DAW. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3, -// NO vendor/ includes. Standard library only. Unit-tested outside the DAW — the same -// "small pure type + length-prefixed round-trip" pattern as provenance / owned_manifest. +// When the extension ingests a sample it writes an assignment request to its +// own ext-state namespace: "the active sampler instance should now play THIS +// sample." Owns only the wire format — the persist shell writes it, the +// instrument reads it, both must agree on the byte layout. The extension +// writing its own namespace does not violate the instrument's +// read-only-over-the-bank rule. // -// -- What it is -------------------------------------------------------------- -// -// When the EXTENSION ingests a sample (S8: arrange capture / Media-Explorer import / -// drop-onto-panel) it writes an ASSIGNMENT REQUEST to its own "reasampler" ext-state -// namespace: "the active sampler instance should now play THIS sample." The value -// names the ingested sample by (bankId, sampleId) plus a monotonic `generation` the -// reader compares to decide the request is NEW (a fresh ingest, even of the same id). -// -// This module owns ONLY the value's WIRE FORMAT — build/parse round-trip. Writing it -// to ext-state is the persist shell's job; READING it is the instrument's job in a -// LATER dispatch (S8 instrument-side follow-up, after S10 merges). This is why the -// format is documented here in the header, not just in code: the reader lands elsewhere -// and must decode exactly what this writer produced. -// -// -- The data-ownership boundary (load-bearing) ------------------------------ -// -// The EXTENSION writes this; the instrument only READS it. That does not violate the -// instrument's read-only-over-the-bank rule: the assignment request is the extension -// writing its OWN namespace (a request FROM the extension TO the instrument), never the -// instrument writing back into the bank. The instrument, on reading a new generation, -// updates its OWN component-state selection (the same selection S4 persists) and reloads. -// -// -- Why `generation` ------------------------------------------------------- -// -// Instances reference sample IDs, so re-assigning the SAME id (e.g. a recapture, or a -// re-drop of the same file) would be indistinguishable from a stale value without a -// changing field. `generation` is a monotonic disambiguator (the ingest writer supplies -// a wall-clock unix-epoch stamp today — see the writer shell) so the reader can tell -// "assigned again just now" from "already saw this." It is DELIBERATELY the same shape -// the S9 bank-generation counter will use, but it is NOT that counter — S9 is a separate -// point; this field is self-contained to the request and does not depend on S9 landing. +// `generation` exists because re-assigning the SAME (bankId, sampleId) would +// be indistinguishable from a stale value without a changing field; the +// writer supplies a unix-epoch stamp so the reader can tell "assigned again +// just now" from "already saw this." #include #include @@ -44,11 +21,8 @@ namespace reasampler::wire { // One assignment request: the ingested sample's identity + a monotonic disambiguator. -// bankId — the bank the sample was ingested into (the active/target bank). -// sampleId — the ingested Sample's stable id (BankModel key). -// generation — a monotonic value the reader compares to detect a NEW request. The -// writer supplies a unix-epoch-seconds stamp; the reader treats it as an -// opaque "did this change?" token, not a wall-clock it interprets. +// generation is an opaque "did this change?" token (writer supplies unix-epoch +// seconds); the reader never interprets it as a wall-clock. struct AssignmentRequest { std::string bankId; std::string sampleId; @@ -61,29 +35,19 @@ struct AssignmentRequest { bool operator!=(const AssignmentRequest& o) const { return !(*this == o); } }; -// Encode an assignment request to the wire string. Length-prefixed fields behind a -// magic+version tag ("rsassign1"), so arbitrary bytes in an id (a GUID, a display- -// derived id) round-trip whole with no escaping ambiguity — the same idiom provenance -// uses. Deterministic: the same request always yields the same string. -// -// FORMAT (documented for the LATER instrument-side reader): +// Length-prefixed fields behind a magic+version tag, so arbitrary bytes in an +// id round-trip whole with no escaping ambiguity. Deterministic. // "rsassign1" ':' ':' ':' -// where each is the decimal byte length of the field that follows the ':'. std::string encodeAssignmentRequest(const AssignmentRequest& req); -// Parse a wire string produced by encodeAssignmentRequest. std::nullopt on any -// malformed / truncated / trailing-garbage input (never UB, never a partial value) — -// the reader shell treats absence/malformed as "no pending request." Round-trips: -// decodeAssignmentRequest(encodeAssignmentRequest(x)) == x. +// std::nullopt on any malformed/truncated/trailing-garbage input (never UB, +// never a partial value); the reader treats that as "no pending request." +// Round-trips: decodeAssignmentRequest(encodeAssignmentRequest(x)) == x. // -// READER REQUIREMENT (instrument-side, S8 follow-up dispatch): after successfully -// decoding a request, the reader MUST verify that (bankId, sampleId) resolves to an -// existing sample before acting on it. An undo on the extension side rolls back the -// `banks` ext-state key (removing the sample) but cannot atomically clear the -// `assign_request` key if the write happened outside the undo block. Even with the -// undo-grouping fix (Major 2), the reader must guard against this: treat an -// unresolvable (bankId, sampleId) pair as a stale/no-op request and discard it -// silently, never crashing or selecting a nonexistent entry. +// Reader requirement: an undo can roll back the `banks` key without atomically +// clearing `assign_request`, so after decoding, the reader must verify +// (bankId, sampleId) still resolves to an existing sample and silently drop it +// otherwise — never crash or select a nonexistent entry. std::optional decodeAssignmentRequest(const std::string& wire); } // namespace reasampler::wire diff --git a/src/core/wire/bytes.h b/src/core/wire/bytes.h index 8b6c5fa..0350191 100644 --- a/src/core/wire/bytes.h +++ b/src/core/wire/bytes.h @@ -1,19 +1,12 @@ -// core/wire/bytes.h — the ONE little-endian byte codec (Q-W2v; audit T4-20). -// Pure, header-only: standard library only — NO REAPER, NO SWELL, NO VST3. +// core/wire/bytes.h — the ONE little-endian byte codec. Pure, header-only: +// standard library only — no REAPER, no SWELL, no VST3. // -// Five hand-rolled LE copies existed at the Q-W0 census (sample_map's -// putU32le/putU64le + ByteReader, capture_realtime's writeU32LE, capture_paths' -// readU32LE lambda, ingest's putU32 lambda, instrument_drop's appendU32LE). This -// template is the single survivor: compile-time dispatched, zero runtime cost, -// entirely off hot paths (serialization / file I/O only). The ComponentState -// codec (component_state_io) is its biggest consumer; the remaining hand-rolled -// copies rewire opportunistically in the waves that already open their files. -// -// Wire formats are FROZEN: putLE/putLE emit exactly the bytes the -// retired putU32le/putU64le emitted (LSB first, fixed width), and ByteReader -// preserves the latch-on-truncation contract (once a read runs past the end, -// ok latches false and every subsequent read yields zeros/empties — a truncated -// blob degrades to a partial parse, never out-of-bounds). +// component_state_io is the biggest consumer. Wire format is FROZEN: putLE +// emits fixed-width LSB-first bytes exactly as the hand-rolled copies it +// replaced did, and ByteReader preserves the latch-on-truncation contract — +// once a read runs past the end, ok latches false and every subsequent read +// yields zeros/empties, so a truncated blob degrades to a partial parse, +// never an out-of-bounds read. #pragma once @@ -50,11 +43,8 @@ inline double bitsToDouble(std::uint64_t bits) { return d; } -// A bounded little-endian reader over a byte blob. Every read is length-checked; -// once a read runs past the end the reader latches `ok=false` and yields zeros, -// so a truncated blob degrades to a partial/empty parse rather than reading out -// of bounds. (The class formerly private to sample_map.cpp, promoted here as the -// codec's tested primitive — T4-20.) +// A bounded little-endian reader over a byte blob (see the file header for the +// truncation-latch contract). struct ByteReader { const std::vector& bytes; std::size_t pos = 0; diff --git a/src/core/wire/ext_state_read.h b/src/core/wire/ext_state_read.h index 0f0ec75..2c9a9f0 100644 --- a/src/core/wire/ext_state_read.h +++ b/src/core/wire/ext_state_read.h @@ -1,29 +1,24 @@ #pragma once -// ext_state_read — the GetProjExtState GROW-LOOP retry policy (T2-04; rehomed to -// core/wire in Q-W6 — its consumers are the extension's persist/usage-scan shells -// AND the instrument's bridge, so it lives on the neutral wire seam rather than in -// the instrument-side bridge_marshal decode helper it started in). +// ext_state_read — the GetProjExtState grow-loop retry policy, shared by the +// extension's persist/usage-scan shells and the instrument's bridge so the +// retry/termination rules cannot drift between them. // -// GetProjExtState writes into a caller-supplied buffer with no documented -// query-the-size call, so a large value (bank blob, usage record) must be read by -// growing a buffer until the value fits strictly inside it. Three shells carried -// hand-rolled copies of that loop (persist's ext-state reads, usage_scan's -// prune-safety-adjacent record read, reaper_bridge's VST-side bank read); the ONE -// policy lives here so the retry/termination rules cannot drift. The fiddly part -// is the termination taxonomy, which each caller folds differently: +// GetProjExtState writes into a caller-supplied buffer with no query-the-size +// call, so a large value must be read by growing a buffer until it fits +// strictly inside it. Termination taxonomy (each caller folds differently): // // * Absent — the API returned <= 0 on some attempt: the key holds no value. // (persist -> "" empty bank; usage_scan / bridge -> nullopt) // * Complete — the written C string fits STRICTLY inside the buffer (size+1 < // cap), so it cannot have been clipped: `value` is the whole value. -// * Overflow — the value never fit under the 16 MB ceiling: it is unreadable -// WHOLE, which is NOT the same as absent. (persist warns on the -// console; usage_scan folds it to the prune fail-safe abort) +// * Overflow — the value never fit under the 16 MB ceiling: unreadable WHOLE, +// NOT the same as absent. (persist warns on console; usage_scan +// folds it to the prune fail-safe abort) // -// `read` is one GetProjExtState-shaped attempt: int read(char* buf, int cap), -// returning the API's int. A template, statically dispatched per call site — no -// virtual calls, no std::function (the §3 performance guardrail); the caller binds -// the project/namespace/key (or a resolved function pointer, VST side) in a lambda. +// `read` is one GetProjExtState-shaped attempt: int read(char* buf, int cap). +// Template, statically dispatched per call site — no virtual calls, no +// std::function (hot-path guardrail); the caller binds project/namespace/key +// in a lambda. #include #include @@ -54,7 +49,7 @@ GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) { result.status = GrowingExtStateRead::Status::Absent; return result; } - buf[static_cast(cap) - 1] = '\0'; // defensive: guard against a read() that fills the buffer without honoring NUL-termination within cap + buf[static_cast(cap) - 1] = '\0'; // guard a read() that ignores NUL-termination within cap std::string s(buf.data()); if (static_cast(s.size()) + 1 < cap) { result.status = GrowingExtStateRead::Status::Complete; diff --git a/src/core/wire/instrument_drop.cpp b/src/core/wire/instrument_drop.cpp index 78c1b0a..3732b32 100644 --- a/src/core/wire/instrument_drop.cpp +++ b/src/core/wire/instrument_drop.cpp @@ -1,14 +1,14 @@ // instrument_drop — pure implementation. See instrument_drop.h. -// NO REAPER / SWELL / VST3 SDK / vendor. Reuses sample_map's ComponentState serializer and -// the SDK-free UID macros (core/wire/reasampler_uid.h). +// No REAPER/SWELL/VST3 SDK/vendor. Reuses sample_map's ComponentState serializer +// and the SDK-free UID macros (reasampler_uid.h). #include "core/wire/instrument_drop.h" #include #include "core/wire/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID -#include "core/instrument/map/component_state_io.h" // ComponentState + serializeComponentState (the SHARED writer, Q-W2v codec split) -#include "core/wire/bytes.h" // putLE — the ONE LE byte codec (T4-20) +#include "core/instrument/map/component_state_io.h" // ComponentState + serializeComponentState (the SHARED writer) +#include "core/wire/bytes.h" // putLE — the ONE LE byte codec namespace reasampler::wire { @@ -18,8 +18,7 @@ using instrument::map::serializeComponentState; namespace { // The .vstpreset container stores its integers little-endian on disk (public.sdk -// vstpresetfile.cpp swaps only on big-endian hosts) — putLE (core/wire/bytes.h) is -// exactly that byte order; the former appendU32LE/appendU64LE copies are retired (T4-20). +// vstpresetfile.cpp swaps only on big-endian hosts) — putLE is exactly that byte order. void appendFourCC(std::vector& out, const char id[4]) { out.insert(out.end(), id, id + 4); @@ -28,9 +27,6 @@ void appendFourCC(std::vector& out, const char id[4]) { } // namespace std::string vstClassIdHex() { - // FUID::toString reduces to the four INLINE_UID words as "%08X" in order on BOTH byte - // layouts (see header contract), so rendering the macros directly is the platform-stable - // derivation of the string the .vstpreset header must carry. char buf[33]; std::snprintf(buf, sizeof(buf), "%08X%08X%08X%08X", static_cast(REASAMPLER_ACTIVE_UID_1), @@ -69,12 +65,8 @@ std::vector buildVstPresetBytes( } std::vector instrumentDropStateBytes(const std::string& sampleId) { - // The ONE fact the drop carries: this capture is the instance's selection. Everything - // else stays at the fresh-instance defaults (no zones, implicit channel mode, generation - // 0) — the same ComponentState a browser click would produce. The implicit mode means - // the GA auto-default will follow the loaded capture's channel count on first reload. - // serializeComponentState is the instrument's own writer (the single source of truth for - // the byte layout), so this is NOT a parallel encoder — it IS the instrument's encoder. + // Everything but selectionId stays at fresh-instance defaults (no zones, + // implicit channel mode, generation 0) — same as a browser click. ComponentState cs; cs.selectionId = sampleId; return serializeComponentState(cs); @@ -85,16 +77,7 @@ std::vector buildInstrumentDropPreset(const std::string& sampleId) } bool infoNamesFxHotspot(const std::string& info) { - // See the header contract. Prefix rule (S-GA-DropFX): "fx_" names the FX-chain / - // floating-FX windows; "tcp.fx" / "mcp.fx" prefixes name the TCP/MCP FX button and its - // sibling FX sub-elements (fxbyp/fxparm/fxlist...), tolerant of the SDK-documented "may - // append additional information". Bare "tcp"/"mcp" and non-FX sub-elements ("tcp.mute", - // "tcp.vol") must NOT trigger an instrument drop. - // - // EXCLUDE the embed-strip sub-element ("tcp.fxembed" / "mcp.fxembed"): that is the - // surface where a ReaSampler 9000 embed strip draws inside the TCP/MCP. Dropping a card - // there must NOT add a SECOND instance — the surface is the existing instance's own UI, - // not an FX-chain drop target. It starts with "tcp.fx" so it must be explicitly excluded. + // See the header contract for the prefix rule and the embed-strip exclusion. auto startsWith = [&info](const char* p) { return info.rfind(p, 0) == 0; }; if (startsWith("tcp.fxembed") || startsWith("mcp.fxembed")) return false; return startsWith("fx_") || startsWith("tcp.fx") || startsWith("mcp.fx"); diff --git a/src/core/wire/instrument_drop.h b/src/core/wire/instrument_drop.h index 25fec92..7363964 100644 --- a/src/core/wire/instrument_drop.h +++ b/src/core/wire/instrument_drop.h @@ -1,36 +1,25 @@ #pragma once -// instrument_drop — the PURE payload-construction core of S17 drop-and-load. +// instrument_drop — pure payload-construction core of drop-and-load: dropping a +// bank capture onto a track's FX surface instantiates ReaSampler 9000 on that +// track already playing that capture. No REAPER/SWELL/VST3 SDK/vendor includes +// (+ the pure sample_map it reuses and the SDK-free UID macros in +// reasampler_uid.h); unit-tested outside the DAW. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO VST3 SDK, -// NO vendor/ includes. Standard library only (+ the pure sample_map it reuses and the -// SDK-free UID macros in core/wire/reasampler_uid.h). Unit-tested outside the DAW — the same -// "small pure builder + round-trip proof" pattern as assignment_request / provenance. +// Mechanism: after TrackFX_AddByName creates the instance, the extension +// writes a Steinberg-format .vstpreset file whose 'Comp' chunk is the +// instrument's own component state (capture pre-selected) and applies it via +// TrackFX_SetPreset — the SDK-documented path for VST3 plug-ins. // -// -- What it is (the S17 seam, extension side) -------------------------------- +// NOT TrackFX_SetNamedConfigParm("vst_chunk", ...): for VST3 that string is +// REAPER's own wrapper framing, not the raw IComponent::setState stream — +// writing raw component-state bytes there "succeeds" but the wrapper cannot +// apply the unframed blob, leaving the instance silently at defaults. The +// .vstpreset container is Steinberg-documented (public.sdk/source/vst/ +// vstpresetfile.cpp is the reference layout) and buildable byte-exactly. // -// S17 drops a bank capture onto a track's FX surface, which instantiates ReaSampler 9000 on -// that track ALREADY PLAYING that capture. The injection mechanism (S-GA-DropFX revision of -// PLAN.md §S17 mechanism (B)): after TrackFX_AddByName creates the instance, the extension -// writes a Steinberg-format .vstpreset file whose 'Comp' chunk is the instrument's own -// component state (the dragged capture pre-selected) and applies it via -// TrackFX_SetPreset(track, fx, ".vstpreset") -// which the SDK documents as accepting full .vstpreset paths for VST3 plug-ins. -// -// WHY NOT vst_chunk (the S-GA-DropFX diagnosis): TrackFX_SetNamedConfigParm's "vst_chunk" -// is "base64-encoded VST-specific chunk" — for a VST3 that is REAPER's OWN wrapper framing -// of the plugin state (the bytes REAPER round-trips into the RPP #include @@ -38,77 +27,45 @@ namespace reasampler::wire { -// The 32-char uppercase-hex class-ID string of THIS build's channel-active ReaSampler 9000 -// VST3 class UID — exactly what Steinberg::FUID::toString renders and what a .vstpreset -// header carries (public.sdk vstpresetfile: "ASCII-encoded FUID"). On both COM-compatible -// (Windows GUID byte order) and plain layouts, FUID::toString reduces to the four -// INLINE_UID uint32 words printed "%08X" in order, so this derivation is platform-stable. -// Sourced from the FROZEN macros in core/wire/reasampler_uid.h (the same constants the factory -// registers), channel-selected by the one REASAMPLER_CHANNEL_IS_BETA bit — a beta extension -// writes presets only the beta VST class accepts, preserving the S18 pairing invariant. +// The 32-char uppercase-hex class-ID string of this build's channel-active +// ReaSampler 9000 VST3 class UID — exactly what Steinberg::FUID::toString +// renders and what a .vstpreset header carries (the four INLINE_UID uint32 +// words printed "%08X" in order, platform-stable on both COM-compatible and +// plain layouts). Sourced from reasampler_uid.h, channel-selected — a beta +// extension writes presets only the beta VST class accepts. std::string vstClassIdHex(); -// Build a Steinberg VST3 preset file image (the bytes of a .vstpreset) carrying exactly one -// 'Comp' chunk = `componentState`, addressed to class `classIdHex32` (32 hex chars, see -// vstClassIdHex). Layout per public.sdk/source/vst/vstpresetfile.cpp, all integers -// little-endian on disk: -// [0] 'VST3' — header magic -// [4] int32 version = 1 -// [8] 32-char ASCII class ID +// Builds a Steinberg VST3 preset image with exactly one 'Comp' chunk = +// `componentState`, addressed to class `classIdHex32` (32 hex chars). Layout +// per public.sdk/source/vst/vstpresetfile.cpp, little-endian: +// [0] 'VST3' [4] int32 version=1 [8] 32-char class ID // [40] int64 chunk-list offset (= 48 + componentState.size()) -// [48] the component-state bytes — the one 'Comp' chunk's data -// then 'List', int32 entry count = 1, then the entry: 'Comp', int64 offset 48, int64 size. -// No 'Cont' chunk is written: the instrument is a SingleComponentEffect whose whole state is -// the component stream; a controller-state chunk is optional in the container format. -// Returns an empty vector when classIdHex32 is not exactly 32 chars (contract violation). +// [48] component-state bytes, then 'List' + entry count=1 + {'Comp', 48, size}. +// No 'Cont' chunk (a SingleComponentEffect's controller state is optional in +// the container format). Empty vector when classIdHex32 isn't 32 chars. std::vector buildVstPresetBytes(const std::string& classIdHex32, const std::vector& componentState); -// The drop payload: a .vstpreset image for the channel-active class whose component state is -// the instrument's default face with just `sampleId` picked — {selectionId = sampleId, no -// zones, mono, generation 0}, exactly what a fresh instance would hold after the user -// clicked that capture in the browser. The keymap builds under the product defaults (Gate + -// Preserve) from the bank's own S2 intrinsics, so the sample plays MIDI-triggered -// immediately (the S17 "loaded, selected, playable" verify). -// -// An EMPTY sampleId yields the empty-state preset ({"", no zones}) — a drop of nothing -// selects nothing (the S10 silent empty state); the shell guards against this upstream, but -// the pure contract is defined. -// -// Deterministic: the same sampleId always yields the same bytes. +// The drop payload: a .vstpreset for the channel-active class with just +// `sampleId` picked (no zones, mono, generation 0) — what a fresh instance +// would hold after a browser click. Empty sampleId yields the empty-state +// preset. Deterministic. std::vector buildInstrumentDropPreset(const std::string& sampleId); -// -- FX-drop-target classification (S-VIEW-BUG-1 / S-GA-DropFX) ---------------- -// -// Pure classifier for GetThingFromPoint's info string: is the point over a surface where an -// instrument drop should instantiate ReaSampler 9000 on the resolved track? This is string -// logic (no REAPER types), so it lives here and is unit-tested outside the DAW — the shell -// (instrument_drop_win) only supplies the info bytes GetThingFromPoint filled. -// -// The SDK (reaper_plugin_functions.h §GetThingFromPoint) documents "fx_chain"/"fx_N" for -// the FX-chain and floating-FX windows, and "tcp"/"mcp"-prefixed strings with sub-element -// tokens ("tcp.mute" is the doc's example) for track-panel hits — WITH the explicit warning -// that "future versions may append additional information". The FX-button sub-token itself -// is undocumented; the WALTER element family names the TCP/MCP FX surfaces "tcp.fx", -// "tcp.fxbyp", "tcp.fxparm", "tcp.fxembed", "mcp.fxlist", ... — all beginning "tcp.fx" / -// "mcp.fx". So the hotspot rule is PREFIX-based (S-GA-DropFX: the earlier exact-token match -// on "tcp.fx"/"mcp.fx" was too strict for appended info and sibling FX elements): -// * "fx_" prefix — the FX-chain and floating-FX windows -// * "tcp.fx" / "mcp.fx" prefix — the TCP/MCP FX button + sibling FX sub-elements -// EXCEPT "tcp.fxembed" / "mcp.fxembed" — the embed-strip surface where a ReaSampler 9000 -// instance draws inside the TCP/MCP. Dropping onto the existing instance's own UI must NOT -// add a second instance; the embed surface is explicitly excluded even though it starts -// with "tcp.fx". All other "tcp.fx*" / "mcp.fx*" tokens (fxbyp, fxparm, fxlist, ...) are -// hotspots — they are FX-chain controls, not a running instance's own surface. -// Bare "tcp"/"mcp" and non-FX sub-elements (e.g. "tcp.mute", "tcp.vol") are NOT hotspots. -// The exact live token over the FX button remains a DAW-only fact — confirm in REAPER (a -// deferred ReaScript around reaper.GetThingFromPoint(reaper.GetMousePosition()) prints it). +// Pure classifier for GetThingFromPoint's info string: is the point over a +// surface where an instrument drop should instantiate ReaSampler 9000? The +// SDK warns future versions may append information, so the rule is +// PREFIX-based: "fx_" (FX-chain/floating windows) or "tcp.fx"/"mcp.fx" (the +// TCP/MCP FX button + sibling elements) EXCEPT "tcp.fxembed"/"mcp.fxembed" — +// the embed-strip surface where an instance already draws; dropping there +// must not add a second instance. Bare "tcp"/"mcp" and non-FX sub-elements +// are not hotspots. The exact live token is DAW-only — confirm via +// reaper.GetThingFromPoint(reaper.GetMousePosition()) in ReaScript if unsure. bool infoNamesFxHotspot(const std::string& info); -// The raw component-state bytes the preset carries — exposed so the round-trip test can -// decode them back through the instrument's OWN reader (sample_map::deserializeComponentState) -// and assert the capture is selected, proving the preset feeds the instrument exactly what -// its setState expects. Not called by the shell (which uses the .vstpreset image). +// The raw component-state bytes the preset carries, exposed so the round-trip +// test can decode them back through sample_map::deserializeComponentState and +// assert the capture is selected. Not called by the shell. std::vector instrumentDropStateBytes(const std::string& sampleId); } // namespace reasampler::wire diff --git a/src/core/wire/reasampler_uid.h b/src/core/wire/reasampler_uid.h index 28e9c0d..93102e5 100644 --- a/src/core/wire/reasampler_uid.h +++ b/src/core/wire/reasampler_uid.h @@ -1,38 +1,31 @@ #pragma once -// reasampler_uid.h — the FOREVER-FROZEN VST3 class-UID constants, SDK-FREE. +// reasampler_uid.h — the FOREVER-FROZEN VST3 class-UID constants, SDK-free. // -// Split out of reasampler_vst.h (S-GA-DropFX) so the PURE extension side can derive the -// class-ID string a .vstpreset file carries (instrument_drop::vstClassIdHex) WITHOUT -// including the VST3 SDK: reasampler_vst.h needs Steinberg::FUID (SDK), but the UID VALUES -// are plain integer macros. This header owns the values + the channel selection; nothing -// else. reasampler_vst.h includes it to build the runtime FUID; instrument_drop includes it -// to render the 32-char hex string. ONE source of truth — the frozen constants are written -// exactly once, here. +// Split out of reasampler_vst.h so the pure extension side can derive the .vstpreset +// class-ID string (instrument_drop::vstClassIdHex) without pulling in the VST3 SDK. +// reasampler_vst.h builds the runtime FUID from these same macros; instrument_drop +// renders the hex string from them — one source of truth, so binary identity and +// preset-file identity cannot diverge. // -// A class UID is FOREVER-STABLE once shipped: a REAPER project that instantiates the -// instrument records the UID, so changing it orphans every saved instance. Minted once; -// do not regenerate. See reasampler_vst.h for the full channel-isolation story (S18). +// FOREVER-STABLE once shipped: a REAPER project that instantiates the instrument +// records the UID, so changing it orphans every saved instance. Never regenerate. #include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — the one channel bit -// STABLE class UID (S-NAME-1). Minted at the S1 spike (2026-07-26), locked. FROZEN FOREVER. +// STABLE class UID. FROZEN FOREVER. #define REASAMPLER_PROC_UID_1 0x5E45A11E #define REASAMPLER_PROC_UID_2 0x9C7B4D6A #define REASAMPLER_PROC_UID_3 0xB1E3F208 #define REASAMPLER_PROC_UID_4 0x4A6C1D9F -// BETA class UID (S18). Minted once (2026-07-26), locked FROM THIS WAVE per Daniel's -// fast-track (fork S18-F1: mint now, not at first beta release). FROZEN FOREVER — the same -// permanent lock as the stable UID; do not regenerate even though no beta VST has shipped. +// BETA class UID. FROZEN FOREVER — locked even though no beta VST has shipped yet. #define REASAMPLER_PROC_UID_BETA_1 0xCCFFEB3A #define REASAMPLER_PROC_UID_BETA_2 0x4FF532A6 #define REASAMPLER_PROC_UID_BETA_3 0x9E181798 #define REASAMPLER_PROC_UID_BETA_4 0x4256955F -// The channel-selected UID macros — exactly one class UID per binary. The factory's -// INLINE_UID (compile-time brace init) and the runtime FUID in reasampler_vst.h both source -// these, as does the extension's vstClassIdHex (the .vstpreset class-ID string), so the -// binary identity and the preset-file identity cannot diverge. +// Channel-selected UID macros — exactly one class UID per binary. The factory, +// the runtime FUID, and vstClassIdHex all source these. #if REASAMPLER_CHANNEL_IS_BETA #define REASAMPLER_ACTIVE_UID_1 REASAMPLER_PROC_UID_BETA_1 #define REASAMPLER_ACTIVE_UID_2 REASAMPLER_PROC_UID_BETA_2 diff --git a/src/core/wire/sample_usage.cpp b/src/core/wire/sample_usage.cpp index 0489650..3257924 100644 --- a/src/core/wire/sample_usage.cpp +++ b/src/core/wire/sample_usage.cpp @@ -12,11 +12,6 @@ namespace { constexpr const char* kMagic = "rsusage1"; -// The shared core/wire codec (Q-W1, T2-01b) — one grammar across every -// ext-state seam. The former local fieldCount (10-digit cap) is subsumed by the -// codec's fieldSizeT (20-digit cap + overflow-guarded accumulate): every count -// the old cap accepted decodes identically, and any larger count is rejected by -// the count-vs-wire-size sanity bound at the call site below. using wire::putField; using Cursor = wire::Cursor; @@ -65,31 +60,20 @@ std::optional decodeUsageRecord(const std::string& wire) { UsagePublishPlan planUsagePublish(const std::optional& existing, const UsageRecord& mine) { UsagePublishPlan plan; - // The written form of "just mine": mine's identity + holds, unioned=false (the plan - // computes the flag; a sole-writer record is un-poisoned). UsageRecord cleanMine = mine; cleanMine.unioned = false; plan.wire = encodeUsageRecord(cleanMine); if (!existing || existing->empty()) { - // Fresh key — write mine. - return plan; + return plan; // fresh key — write mine } + const std::optional theirs = decodeUsageRecord(*existing); if (!theirs) { - // Undecodable existing value under MY key: corruption (a sibling sharing - // this key via copy always writes decodable records). REMINT rather than - // overwrite: writing mine over the corrupt key would clear the prune-side - // abort, but a same-key sibling B's holds would then be unprotected until - // B publishes again. Leaving the corrupt key in place keeps the prune-side - // abort firing (foldUsageRecords.abortPrune) so the window where B's holds - // might be unprotected can never resolve toward delete. Mine is published - // under the new key that remint produces. - // NOTE (>16 MB gap): readReasamplerExtState returning nullopt for a value - // larger than 16 MB is indistinguishable from "absent" at the publish site; - // that narrow case takes the fresh-write branch above rather than remint. - // Both outcomes are safe (fresh write is also correct for a truly absent key); - // the gap is documented in the header's fail-safe list. + // Corrupt value under my key: remint rather than overwrite. Overwriting + // would clear the prune-side abort currently protecting a same-key + // sibling's (possibly unprotected) holds; leaving the corrupt key in + // place keeps that abort firing until the sibling republishes. plan.remint = true; return plan; } @@ -98,21 +82,16 @@ UsagePublishPlan planUsagePublish(const std::optional& existing, !mine.ownerNonce.empty() && theirs->ownerNonce == mine.ownerNonce; if (nonceMatch && !theirs->unioned) { - // Exactly THIS incarnation wrote the key (the per-lifetime nonce is the exact - // ownership proof — a same-track sibling's byte-identical hold set can NOT pass - // this test, its nonce differs) AND no other writer has ever unioned into it, - // so the content is provably all mine. Clean replace: released holds drop. + // Exactly this incarnation wrote the key last and it was never unioned + // by another writer — content is provably all mine. if (plan.wire == *existing) plan.skipWrite = true; // idle reload tick return plan; } if (theirs->trackGuid == mine.trackGuid || (nonceMatch && theirs->unioned)) { - // A foreign writer on MY OWN track (a same-track copy-sibling, or my own - // last-session record — indistinguishable by construction), or a record I - // wrote last but that carries unioned holds from an earlier multi-writer - // merge. Either way no hold in it may be dropped by me — union, existing- - // first, de-duped, and the record is (or stays) POISONED unioned=true so no - // future nonce-matching write can clean-replace a sibling's holds away. + // Same-track sibling, my own last-session record, or an already-unioned + // record — no hold in it may be dropped. Union, existing-first, de-duped, + // poisoned unioned=true so a future clean replace can never drop it. UsageRecord merged; merged.trackGuid = mine.trackGuid; merged.ownerNonce = mine.ownerNonce; @@ -126,18 +105,16 @@ UsagePublishPlan planUsagePublish(const std::optional& existing, if (!dup) merged.holds.push_back(h); } if (theirs->unioned && merged.holds == theirs->holds) { - // Already poisoned and the union adds nothing — the write would flip only - // the ownerNonce. Skip the redundant ext-state churn. (A false->true - // unioned flip is NEVER skipped: it is the poison that protects the other - // writer's holds from the last writer's future clean replace.) + // Already poisoned and the union adds nothing -> the write would only + // flip ownerNonce; skip. A false->true unioned flip is NEVER skipped. plan.skipWrite = true; } plan.wire = encodeUsageRecord(merged); return plan; } - // Foreign value from ANOTHER track: this instance is a cross-track copy (or was - // moved). Take a fresh identity; never overwrite the other's record. + // Foreign value from another track: a cross-track copy or move. Fresh + // identity; never overwrite the other's record. plan.remint = true; return plan; } @@ -175,16 +152,11 @@ UsageFoldResult foldUsageRecords( records.reserve(decoded.size()); for (const std::optional& rec : decoded) { if (!rec) { - // A present-but-unreadable record: it may protect ANYTHING, so the prune - // must halt outright. Belt-and-braces: return the PROTECT-ALL set (all - // readable records' paths) so the fail-safe holds even under a future - // caller that forgets to check abortPrune before using heldPaths. The - // abort flag is still the authoritative signal; heldPaths is the - // maximum-protection fallback. + // Present-but-unreadable record: it may protect anything, so halt. + // Belt-and-braces: also return the protect-all set (every readable + // record's paths, bypassing the liveness filter) so the fail-safe + // holds even if a future caller forgets to check abortPrune first. result.abortPrune = true; - // Collect EVERY path from EVERY readable record, bypassing the liveness - // filter entirely (on abort the protected set is unknowable, so every - // decoded hold must be included regardless of track-guid membership). std::unordered_set seen; for (const std::optional& r : decoded) { if (!r) continue; @@ -214,19 +186,11 @@ bool identityMatches(const std::string& identity, const std::string& uidHexUpper const std::string& outputNameUpper) { if (identity.empty()) return false; const std::string up = toUpperAscii(identity); - // Primary: the 32-hex class UID embedded in REAPER's fx_ident rendering. Not - // guaranteed on every platform/REAPER build (byte-order of the rendered FUID vs - // REAPER's hex is unverified on Windows COM layout), hence the two name nets below - // — and the protect-all fold above them (see usageHeldPaths). + // Class-UID byte-order in fx_ident is unverified on Windows COM layout, + // hence the two name fallbacks below (see header for the protect-all net). if (!uidHexUpper.empty() && up.find(uidHexUpper) != std::string::npos) return true; - // The module filename base ("REASAMPLER_9000") — fx_ident carries the .vst3 module - // path, so this is the alternative that works in the common case (the display name - // "REASAMPLER 9000", space-separated, can never match the filename form). if (!outputNameUpper.empty() && up.find(outputNameUpper) != std::string::npos) return true; - // The factory display name — matches original_name / renamed-instance renderings. - // Beta-substring over-protect is deliberate (see the header note): stable needles - // are substrings of beta ones, widening protection only — never a delete. return !nameUpper.empty() && up.find(nameUpper) != std::string::npos; } diff --git a/src/core/wire/sample_usage.h b/src/core/wire/sample_usage.h index 8553bf0..a42e6f5 100644 --- a/src/core/wire/sample_usage.h +++ b/src/core/wire/sample_usage.h @@ -1,104 +1,66 @@ #pragma once -// sample_usage — the pure core of the pS-usage seam: ReaSampler 9000 instances count -// as USAGE for the prune. Each live instance PUBLISHES the captures it holds (its v10 -// SampleRefs — sample ids + project-relative paths) to a per-instance project ext-state -// key ("rsusage_", see ext_keys.h); the EXTENSION reads every usage record -// at prune-scan time, keeps only the records backed by a live ReaSampler 9000 FX -// instance, and folds the surviving paths into the prune's `referenced` set — so a file -// any live instance holds can never be an orphan and BANK_PRUNE_FOLDER can never -// delete it. +// sample_usage — pure core of the instance-usage wire: ReaSampler 9000 instances +// count as usage for the prune. Each live instance publishes the captures it +// holds to a per-instance ext-state key ("rsusage_"); the +// extension reads every record at prune-scan time, keeps only the ones backed +// by a live FX instance, and folds the surviving paths into the prune's +// `referenced` set — a file any live instance holds can never be an orphan. // -// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO VST3, NO SWELL, -// NO vendor/ includes. Standard library only. The mirror of assignment_request (the -// other VST<->extension ext-state wire): the wire format AND the two safety-critical -// decisions (what to write on publish, which records count at prune time) live here so -// they are provable without a DAW. The shells only move strings. +// No REAPER/VST3/SWELL/vendor includes. 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 and provable without a DAW; shells only move strings. // -// -- The data-ownership boundary (load-bearing) ------------------------------- +// The INSTRUMENT writes usage keys, the EXTENSION only reads them — the one +// sanctioned instrument->ext-state write. It does not weaken the +// read-only-bank invariant: the instrument publishes only its own +// per-instance key, never banks/view/tail/assign; the bridge's write entry +// point structurally accepts only "rsusage_"-prefixed keys. // -// The INSTRUMENT writes usage keys; the EXTENSION reads them. This is the ONE sanctioned -// instrument->ext-state write (Daniel's ruling: "if that means the VST writes to the -// bridge when it grabs a capture, so be it") and it does NOT weaken the read-only-BANK -// invariant: the instrument publishes its OWN usage under its OWN per-instance key, -// and never touches banks/view/tail/assign or any other extension-owned key. The bridge -// enforces this structurally — its write entry point accepts only "rsusage_"-prefixed keys. +// THE SAFETY PROPERTY (overrides every other consideration): every failure, +// ambiguity, or uncertainty here must fail-safe toward PROTECT. Over-protection +// (prune skips a reclaimable file, or refuses to run) is an accepted residual; +// under-protection (deleting a file an instance may still be playing) is a +// data-loss bug. Three folds enforce this: +// * sibling-collision -> UNION, never clean-replace over a foreign writer; +// * zero-identified -> records exist but no instance was identified live -> +// protect ALL records' paths (a matcher failure must +// never degrade toward delete); +// * unreadable record -> ABORT the prune entirely (a record we cannot read +// may protect anything; halting deletes nothing). // -// -- THE SAFETY PROPERTY (overrides every other consideration) ----------------- +// Liveness is decided extension-side at read time, not by teardown clearing +// (REAPER destroys the plugin instance when an FX goes offline, including +// Design View's CPU-park, so a terminate-time clear would strip a still-live +// instance's record) or challenge/response (a closed-editor instance could +// never answer a prune-time challenge). Publishing is eager instead (on load +// + every play-set change). // -// The un-prunable guarantee is a SAFETY property: every failure, ambiguity, or -// uncertainty in this seam must FAIL-SAFE toward PROTECT. Over-protection (prune skips a -// reclaimable file, or refuses to run at all) is an acceptable residual; under-protection -// (deleting a file an instance may still be playing) is a data-loss bug. Three fail-safe -// folds live in this pure module so they are provable without a DAW: -// * sibling-collision -> UNION, never clean-replace over a foreign writer (ownerNonce); -// * zero-identified -> records exist but NO instance was identified live -> protect -// ALL records' paths (an identity-matcher failure must never -// degrade toward delete); -// * unreadable record -> ABORT the prune entirely (foldUsageRecords.abortPrune — a -// record we cannot read may protect anything; halting deletes -// nothing). Residual: readReasamplerExtState returning nullopt -// for a >16 MB value is indistinguishable from "absent" at the -// publish site — that narrow case takes the fresh-write branch -// (not remint), noted here for completeness. +// The liveness rule (usageHeldPaths): a record counts iff its track still +// hosts >= 1 instance (offline included — a parked instance still protects +// its holds). A record with no resolvable track GUID counts while ANY +// instance exists (fail-safe fallback). Zero instances identified anywhere -> +// EVERY record's paths protected. // -// -- Liveness (no stale-key false-protect, no false-delete) -------------------- -// -// A usage record must protect exactly the captures of instances that still EXIST. Two -// rejected designs shape the rules below: -// * NO teardown clearing. The obvious "clear my key in terminate()" is WRONG here: -// REAPER destroys the plugin instance when an FX is set OFFLINE — including the -// extension's own Design View CPU-park (per-FX offline on inactive-mode tracks). A -// terminate-time clear would strip the record of an instance that still exists in -// the project, opening a prune-deletes-a-used-file window. Records are therefore -// never cleared by the instrument; staleness is resolved by the EXTENSION at read -// time against the live FX enumeration. -// * NO challenge/response. Instances only poll ext-state on the EDITOR's UI timer -// (pollBankSync); a closed-editor instance could never answer a prune-time -// challenge, and its holds would be false-deleted. Publishing is therefore EAGER -// (on load + on every play-set change via reloadInstrument), and liveness is -// decided extension-side. -// -// The liveness rule (usageHeldPaths): a record counts iff the track it was published -// from still exists AND that track still hosts at least one ReaSampler 9000 FX -// instance (offline FX included — chain enumeration is chunk-level, so a parked -// instance still protects its holds). A record whose track GUID could not be resolved -// at publish time (empty) counts while ANY ReaSampler 9000 instance exists in the -// project — the fail-safe fallback. And the identity-failure net: when records exist -// but ZERO instances were identified live anywhere, EVERY record's paths are protected -// (see the safety property above — indistinguishable from a matcher failure, so it may -// never resolve toward delete). Residuals: a deleted instance whose track still hosts a -// sibling 9000 keeps its record alive, and a project whose instances were all deleted -// keeps its leftover records protecting until an instance is identified again — both -// false-PROTECT only, bounded, documented, accepted. -// -// -- Identity & the copy problem (planUsagePublish) ----------------------------- -// -// The publishing key is a minted per-instance GUID persisted in ComponentState (v11). -// A persisted id is inherently COPYABLE (FX copy / track duplication clones component -// state byte-for-byte), so two live instances can wake up sharing one key. Worse, two -// same-track copies converge on byte-identical wires, so "existing == what I last -// wrote" is NOT a sound ownership test — a sibling's byte-identical write would pass -// it, and a later clean replace would silently drop the sibling's holds (the delete -// direction). TWO in-wire facts close this: -// * ownerNonce — a per-LIFETIME nonce minted fresh in memory each instance lifetime, -// NEVER persisted (a persisted nonce would clone with the state, recreating the -// ambiguity). Proves "exactly this incarnation wrote the key last". -// * unioned — a STICKY multi-writer poison flag. "I wrote the key last" does NOT -// imply "the key contains only my holds": after I union a sibling's holds under my -// own nonce, a later nonce-matching clean replace would drop them. So the first -// union sets unioned=true in the wire, and a unioned record REFUSES clean replace -// forever — every subsequent write is a union (holds only accumulate). Over-protect -// residual, accepted; a solo never-restarted instance keeps clean-replace -// semantics, and a remint starts a fresh un-poisoned key. -// The publish plan resolves every collision in the fail-safe direction: -// * existing ownerNonce == mine AND not unioned -> clean replace (sole writer, -// provably my content; holds the instance released genuinely drop). -// * same track with a foreign nonce, OR unioned -> UNION of holds, written with -// unioned=true (a same-track sibling, my own last-session record, or a -// multi-writer key; nothing may be dropped — over-protects, never under-protects). -// * foreign nonce, DIFFERENT track, not unioned-by-me -> RE-MINT (a cross-track copy -// or move; the newcomer takes a fresh identity and leaves the original's record -// untouched; a moved-away original's old record dies by the liveness rule). +// Identity & the copy problem (planUsagePublish): the publishing key is a +// per-instance GUID persisted in ComponentState — inherently copyable (FX +// copy / track duplication clones it byte-for-byte), so two live instances can +// share one key, and same-track copies converge on byte-identical wires, so +// "existing == what I last wrote" is not a sound ownership test. Two in-wire +// facts close this: +// * ownerNonce — a per-lifetime nonce, minted fresh in memory, NEVER +// persisted (a persisted nonce would clone with the state). Proves +// "exactly this incarnation wrote the key last." +// * unioned — a sticky poison flag: once a sibling's holds are unioned in, +// the record refuses clean replace forever (every subsequent write unions, +// holds only accumulate) — over-protect residual, accepted. +// Publish resolution, always leaning over-protect: +// * ownerNonce matches mine AND not unioned -> clean replace (sole writer; +// released holds drop). +// * same track with a foreign nonce, OR unioned -> UNION, written unioned=true. +// * foreign nonce, different track -> RE-MINT under a fresh key; the +// original's record is untouched and dies later by the liveness rule if +// abandoned. #include #include @@ -119,14 +81,10 @@ struct UsageHold { } }; -// One instance's published usage: the REAPER track GUID it was hosted on at publish -// time ("{...}" canonical form; empty when the host context could not resolve one), the -// writing incarnation's per-LIFETIME ownerNonce (the exact "did I write this?" ownership -// discriminator — see the copy-problem note above; never persisted in ComponentState), -// the sticky multi-writer `unioned` poison flag (once true, clean replace is refused -// forever — see the note above), plus every capture it holds. The record is -// self-contained — the extension needs nothing from the instance beyond this value and -// the live FX enumeration. +// One instance's published usage: the track GUID it was hosted on at publish +// time (empty if unresolvable), the writing incarnation's ownerNonce, the +// sticky `unioned` poison flag, plus every capture it holds — self-contained, +// the extension needs nothing beyond this value and the live FX enumeration. struct UsageRecord { std::string trackGuid; std::string ownerNonce; @@ -139,29 +97,22 @@ struct UsageRecord { } }; -// Encode a usage record to the wire string. Length-prefixed fields behind a magic tag -// ("rsusage1"), the same idiom as assignment_request / provenance, so arbitrary bytes -// in a GUID or path round-trip whole. Deterministic. -// -// FORMAT: "rsusage1" ':' ':' ':' -// ':' then per hold: ':' ':' +// Length-prefixed fields behind a magic tag ("rsusage1"), same idiom as +// assignment_request, so arbitrary bytes in a GUID or path round-trip whole. +// "rsusage1" ':' ':' ':' +// ':' then per hold: ':' ':' std::string encodeUsageRecord(const UsageRecord& rec); -// Parse a wire string produced by encodeUsageRecord. std::nullopt on malformed / -// truncated / trailing-garbage input (never UB, never a partial value). The prune scan -// treats an undecodable record as UNREADABLE and ABORTS (foldUsageRecords) — it must -// never proceed with protection it cannot read. +// std::nullopt on malformed/truncated/trailing-garbage input (never UB, never +// a partial value). The prune scan treats an undecodable record as unreadable +// and aborts (foldUsageRecords) rather than proceed with protection it cannot read. std::optional decodeUsageRecord(const std::string& wire); -// The publish decision computed BEFORE a write (see the identity note above). -// * remint — true when the existing key value belongs to a live foreign instance -// on another track: the caller must mint a fresh instance GUID and -// write under the NEW key, leaving the existing record untouched. -// * skipWrite — true when the write would change nothing that matters: byte-identical -// to the existing value (idle reload tick), or a union over an -// ALREADY-unioned record that adds no holds (the write would flip only -// the ownerNonce — redundant ext-state churn, skipped; a false->true -// unioned flip is never skipped, it is the multi-writer poison). +// The publish decision computed before a write. +// * remint — the existing key belongs to a live foreign instance on +// another track: mint a fresh instance GUID, write under it. +// * skipWrite — the write would change nothing that matters (byte-identical, +// or a union over an already-unioned record adding no holds). // * wire — the encoded value to write (mine, or the same-track union). struct UsagePublishPlan { bool remint = false; @@ -169,57 +120,32 @@ struct UsagePublishPlan { std::string wire; }; -// Decide what to write for `mine` given the key's current value. `mine.ownerNonce` is -// THIS lifetime's nonce; `mine.unioned` is ignored (the plan computes the written -// flag). Branches, in order: -// * existing absent/empty -> write mine (unioned=false — sole known writer). -// * existing undecodable -> REMINT (mine, unioned=false, under a fresh key) -// rather than overwriting the corrupt key: overwriting would clear the prune-side -// abort, leaving a same-key sibling's holds unprotected until it republishes. -// Leaving the corrupt key in place keeps the prune-side abort (foldUsageRecords) -// firing so no delete-ward window opens. The sibling writes its own decodable -// record on the next publish tick; the corrupt key is eventually evicted once no -// live instance references it. Narrow gap: a >16 MB value reads back as nullopt -// (indistinguishable from absent), so it takes the fresh-write branch rather than -// remint — both outcomes are safe; the gap is noted in the header's fail-safe list. -// * nonce match AND !unioned -> clean replace (sole writer, provably my content; -// released holds drop); skipWrite when -// byte-identical (idle reload tick). -// * same track OR unioned -> union(existing.holds, mine.holds), existing-first, -// de-duped, written with unioned=TRUE under my -// nonce — a sibling's holds are NEVER dropped. The -// false->true unioned flip is ALWAYS written (it is -// the poison that blocks the last writer's future -// clean replace); skipWrite only when the existing -// record is already unioned AND the union adds no -// holds (the write would change nonce only). -// * else (foreign, other track) -> remint = true, write mine (fresh un-poisoned key). +// Decide what to write for `mine` given the key's current value, in order: +// * absent/empty -> write mine (unioned=false). +// * undecodable -> REMINT under a fresh key rather than overwrite +// the corrupt value — overwriting would silently clear the prune-side abort +// that is currently protecting a same-key sibling's unreadable holds. +// * nonce match, !unioned -> clean replace (released holds drop). +// * same track, or unioned -> union(existing, mine), written unioned=true — +// a sibling's holds are never dropped. +// * foreign nonce, other track -> remint (fresh un-poisoned key). UsagePublishPlan planUsagePublish(const std::optional& existing, const UsageRecord& mine); -// The prune-side liveness fold: every project-relative path held by a LIVE instance, -// de-duped, in (record, hold) input order. A record counts iff -// * its trackGuid is non-empty and present in `liveTrackGuids` (a track that still -// exists AND still hosts >= 1 ReaSampler 9000 FX — the caller's enumeration), OR -// * its trackGuid is empty and `anyInstanceLive` is true (the fail-safe fallback for -// a record published without a resolvable track context). -// FAIL-SAFE NET (the safety property): when `records` is non-empty and -// `anyInstanceLive` is false — records exist but NOT ONE instance was identified -// anywhere — EVERY record's paths are returned (protect-all). Zero identified with -// records present is indistinguishable from an identity-matcher failure, and a matcher -// failure must never resolve toward delete. (Residual: leftover records in a project -// whose instances were all genuinely deleted keep protecting — false-PROTECT only.) -// Holds with an empty relativePath are skipped (nothing to protect). +// The prune-side liveness fold: every project-relative path held by a live +// instance, de-duped, in (record, hold) order. A record counts iff its +// trackGuid is present in `liveTrackGuids`, or its trackGuid is empty and +// `anyInstanceLive` is true. FAIL-SAFE NET: when `records` is non-empty and +// `anyInstanceLive` is false, EVERY record's paths are returned (protect-all; +// see the safety property above). Holds with an empty relativePath are skipped. std::vector usageHeldPaths( const std::vector& records, const std::unordered_set& liveTrackGuids, bool anyInstanceLive); -// The prune-side entry fold over RAW read/decode results, one element per enumerated -// rsusage_* key: nullopt = the key was present but could not be read or decoded -// (oversized ext-state read, truncation, corruption). ANY nullopt sets abortPrune — -// the prune must HALT and delete nothing (an unreadable record may protect anything; -// proceeding with degraded protection is the delete direction). Otherwise delegates to +// The prune-side entry fold over raw read/decode results, one element per +// enumerated rsusage_* key: nullopt = present but unreadable/undecodable. ANY +// nullopt sets abortPrune (halt, delete nothing); otherwise delegates to // usageHeldPaths (including its protect-all net). struct UsageFoldResult { bool abortPrune = false; @@ -230,20 +156,15 @@ UsageFoldResult foldUsageRecords( const std::unordered_set& liveTrackGuids, bool anyInstanceLive); -// FX-identity match for the live-instance enumeration (pure so the matcher itself is -// testable; the shell only supplies REAPER's identity strings). `identity` is the value -// of an FX's "fx_ident" or "original_name" named-config parm; the three needles are the -// UPPERCASED channel constants: -// * uidHexUpper — the 32-hex VST3 class UID (instrument_drop::vstClassIdHex), -// * nameUpper — the factory display name ("REASAMPLER 9000"), -// * outputNameUpper— the .vst3 module filename base ("REASAMPLER_9000") — the form -// fx_ident is guaranteed to embed (it carries the module path), -// which the space-separated display name can never match. -// Substring, case-insensitive. NOTE the deliberate beta-substring over-protect: the -// stable needles are substrings of the beta ones ("REASAMPLER 9000" ⊂ "REASAMPLER 9000 -// BETA", "REASAMPLER_9000" ⊂ "REASAMPLER_9000_BETA"), so a stable extension scanning a -// project with beta instances matches them too — a WIDER protected set only (fail-safe; -// it can never cause a delete). +// FX-identity match for the live-instance enumeration (pure so the matcher is +// testable; the shell supplies REAPER's identity strings). `identity` is an +// FX's "fx_ident" or "original_name" parm; the needles are the UPPERCASED +// channel constants — uidHexUpper (32-hex class UID), nameUpper (factory +// display name), outputNameUpper (.vst3 filename base, the form fx_ident is +// guaranteed to embed). Substring, case-insensitive. Deliberate beta-substring +// over-protect: stable needles are substrings of the beta ones, so a stable +// extension matches beta instances too — a wider protected set only, never a +// delete risk. bool identityMatches(const std::string& identity, const std::string& uidHexUpper, const std::string& nameUpper, const std::string& outputNameUpper); diff --git a/src/core/wire/wire.cpp b/src/core/wire/wire.cpp index 80c08b4..f0dd2a6 100644 --- a/src/core/wire/wire.cpp +++ b/src/core/wire/wire.cpp @@ -1,7 +1,5 @@ -// core/wire implementation — see wire.h. The bodies are the hardened -// assignment_request / sample_usage / provenance (post Q-W0 T2-01a backport) -// cursor, unified; any behavioral change here changes every ext-state wire -// seam at once. +// core/wire implementation — see wire.h. Any behavioral change here changes +// every ext-state wire seam at once. #include "core/wire/wire.h" diff --git a/src/core/wire/wire.h b/src/core/wire/wire.h index 9cdde10..608a284 100644 --- a/src/core/wire/wire.h +++ b/src/core/wire/wire.h @@ -1,24 +1,16 @@ -// core/wire — the ONE length-prefixed ext-state wire codec (Q-W1; audit -// T2-01(b)). Pure: standard library only — NO REAPER, NO SWELL, NO VST3. -// -// The `':'` field grammar ("one grammar across every -// ext-state seam") was previously implemented as three near-identical -// putField + Cursor copies (provenance / assignment_request / sample_usage) -// plus a fourth guarded decimal accumulate (bank_sync::parseBankGeneration) — -// and the copies drifted on the hardening. This is the single survivor, -// carrying the FULL hardening everywhere: +// core/wire — the ONE length-prefixed ext-state wire codec, `':' +// `, shared across every ext-state seam. Pure: standard library only — +// no REAPER, no SWELL, no VST3. Hardening carried everywhere: // - length digit-run capped at 20 (SIZE_MAX's decimal width) so a crafted // digit run cannot accumulate past SIZE_MAX via repeated multiply; // - overflow guard on every accumulate (multiply+add checked BEFORE applied); // - subtraction-first bounds check so a huge len cannot wrap `start + len`; // - fieldInt/fieldInt64 parse sign+digits manually with an INT64 overflow -// guard and an int range check — an out-of-range field FAILS the parse -// (closing the strtol errno/range gap the provenance copy carried). +// guard and an int range check — an out-of-range field FAILS the parse. // -// Wire formats on disk / ext-state are FROZEN: encode is byte-identical to the -// pre-collapse writers (std::to_string length + ':' + bytes), decode is -// tolerant-identical for every value a house writer can emit. "Never UB, never -// a partial value" is the parse-integrity promise. +// Wire format is FROZEN: encode is byte-identical to the writers this +// replaced (std::to_string length + ':' + bytes); decode never UB, never a +// partial value. #pragma once @@ -31,10 +23,9 @@ namespace reasampler::wire { // Append one length-prefixed field: ':' void putField(std::string& out, const std::string& field); -// Whole-string, non-negative decimal parse WITHOUT exceptions or locale -// surprises (the bank_sync generation-stamp core). False on empty, any -// non-digit (incl. a leading '+'/'-'), or overflow past INT64_MAX; the -// accumulate is overflow-guarded so a pathologically long digit run can never +// Whole-string, non-negative decimal parse without exceptions or locale +// surprises. False on empty, any non-digit (incl. leading '+'/'-'), or +// overflow past INT64_MAX; overflow-guarded so a long digit run can never // wrap into a bogus small value. bool parseUnsignedDecimal(const std::string& s, std::int64_t& out); @@ -51,30 +42,24 @@ public: // Consumes an exact literal at the cursor (the magic tag). Fails if absent. bool literal(const char* lit); - // Reads one length-prefixed field into `out`. Fails on a missing ':', an - // empty or non-numeric length, a length that would overflow SIZE_MAX, or a - // length that runs past the end. + // Fails on a missing ':', an empty/non-numeric length, an overflow past + // SIZE_MAX, or a length running past the end. bool field(std::string& out); - // Length-prefixed signed 64-bit decimal (optional leading '-'). Digit run - // capped at 19 (INT64_MAX's decimal width); overflow fails the parse. A - // 20-digit negative (only INT64_MIN itself) is conservatively rejected — + // Length-prefixed signed 64-bit decimal. Digit run capped at 19; a + // 20-digit negative (only INT64_MIN) is conservatively rejected too — // house writers emit generation timestamps and small enums, never that. bool fieldInt64(std::int64_t& out); - // fieldInt64 narrowed to int; a value outside [INT_MIN, INT_MAX] FAILS the - // parse (the fixed form of the provenance copy's silent strtol narrowing). + // fieldInt64 narrowed to int; out-of-[INT_MIN, INT_MAX] FAILS the parse. bool fieldInt(int& out); - // Length-prefixed unsigned decimal (element counts). Digit run capped at - // 20; overflow-guarded accumulate. Callers still apply their own - // count-vs-wire-size sanity bound BEFORE any reserve() on the result. + // Length-prefixed unsigned decimal (element counts). Callers still apply + // their own count-vs-wire-size sanity bound BEFORE any reserve(). bool fieldSizeT(std::size_t& out); - // Length-prefixed %.17g double. Full-token strtod; trailing bytes fail. - // Deliberately NO errno/ERANGE rejection: the writers emit %.17g of live - // doubles (incl. "inf"), and those must decode back — same accept set as - // every prior copy. + // Length-prefixed %.17g double. Deliberately no errno/ERANGE rejection: + // writers emit %.17g of live doubles (incl. "inf"), which must decode back. bool fieldDouble(double& out); private: diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp index 3f7d92d..af0bae5 100644 --- a/src/shell/persist/ext_state_io.cpp +++ b/src/shell/persist/ext_state_io.cpp @@ -1,26 +1,22 @@ -// ext_state_io.cpp — the ext-state ↔ JSON serialization half of the persist seam -// (Q-W5 split of the former persist.cpp; see session.h for the TU map and -// ext_state_io.h for the key contract): the session's save/load/assignment-request -// bridge, plus the shared persist_detail helpers (active-project read, growing -// ext-state read, GUID minting, bank-folder relocation) the sibling TUs call. +// ext_state_io.cpp — the ext-state <-> JSON serialization half of the persist +// seam (see session.h for the TU map, ext_state_io.h for the key contract): +// the session's save/load/assignment-request bridge, plus the shared +// persist_detail helpers the sibling TUs call. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API // pointers; here they are extern (CLAUDE.md §contract). // -// Storage: SetProjExtState / GetProjExtState, namespace "reasampler". Phase B: the -// whole BankBook (pool as bank-zero + named banks) is written under key "banks" -// (authoritative); the legacy single-bank key "bank_index" is RETIRED — cleared on -// save (SetProjExtState with "" deletes it) and read only once, to migrate a pre- -// multi-bank project's index into the pool. Ext state is stored INSIDE the .rpp, so -// the banks travel with the project automatically (CONTEXT.md §Persistence & paths). -// The only thing that does NOT travel for free is the physical bank folder; on -// Save-As to a new directory we relocate it so the indices' relative paths still -// resolve (poll(), session.cpp, executes the relocation this TU implements). +// Storage: SetProjExtState/GetProjExtState, namespace "reasampler". The whole +// BankBook (pool as bank-zero + named banks) is written under key "banks" +// (authoritative); the legacy single-bank key "bank_index" is retired — +// cleared on save, read only once to migrate a pre-multi-bank project into +// the pool. Ext state is stored inside the .rpp, so the banks travel with the +// project automatically; the physical bank folder does not, so a Save-As to a +// new directory relocates it (poll(), session.cpp). // -// NON-DESTRUCTIVE: this module writes ONLY our own ext-state key and moves ONLY -// our own reasampler_bank/ folder. It never touches the user's media, items, or -// other ext-state namespaces. +// Non-destructive: this module writes only our own ext-state keys and moves +// only our own reasampler_bank/ folder. #include "shell/persist/ext_state_io.h" @@ -53,11 +49,9 @@ namespace reasampler::persist_detail { namespace fs = std::filesystem; -// Read the active project pointer and its .rpp path in one shot. idx=-1 is the -// current project tab (SDK header line ~1262). The out-buffer receives the full -// .rpp path, EMPTY for a never-saved project (the reliable unsaved sentinel — -// same fact capture.cpp relies on). Returns nullptr proj only when there is no -// active project at all. +// idx=-1 is the current project tab. rppPathOut is empty for a never-saved +// project (the reliable unsaved sentinel); returns nullptr only with no active +// project at all. void* readActiveProject(std::string& rppPathOut) { std::vector buf(4096, '\0'); ReaProject* proj = EnumProjects(-1, buf.data(), static_cast(buf.size())); @@ -65,24 +59,13 @@ void* readActiveProject(std::string& rppPathOut) { return proj; } -// Parent directory of the .rpp, forward-slashed, no trailing slash. Empty in -> -// empty out. Mirrors capture.cpp's derivation so the bank sits alongside the -// .rpp (NOT GetProjectPathEx, which returns the recording path — see capture.cpp -// for the full rationale). The derivation itself is projectDirOfRpp in capture_paths -// (pure) — the SAME convention the VST3 instrument resolves audio paths by, so both -// artifacts share one implementation rather than duplicating the parent-of-.rpp step. +// NOT GetProjectPathEx, which returns the recording path, not the .rpp's own +// directory. Delegates to the pure projectDirOfRpp so both artifacts share one +// implementation. std::string projectDirOf(const std::string& rppPath) { return capture::projectDirOfRpp(rppPath); } -// GetProjExtState needs a caller-supplied buffer; the index JSON can be large -// (many samples). The grow-until-strict-fit retry policy is the SHARED pure -// wire::readProjExtStateGrowing (T2-04 — the same policy the usage_scan and -// VST-bridge reads run); this wrapper binds the REAPER call and -// folds the terminal cases persist's callers expect: "" for an absent key (a valid -// empty bank, not an error) and a console warning + "" for a value exceeding the -// 16 MB ceiling, so an over-large value reads as "too large to load", not silent -// data loss (mirrors the malformed-JSON warning in loadFromProject). std::string getProjExtStateString(void* proj, const char* ns, const char* key) { using wire::GrowingExtStateRead; const GrowingExtStateRead read = wire::readProjExtStateGrowing( @@ -103,8 +86,7 @@ std::string getProjExtStateString(void* proj, const char* ns, const char* key) { return {}; } -// The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. -// guidToString wants a >=64-char destination (SDK header line ~3846). +// guidToString wants a >=64-char destination. std::string genProjectGuidString() { GUID g{}; genGuid(&g); @@ -113,13 +95,8 @@ std::string genProjectGuidString() { return std::string(buf); } -// Ensure a SAVED project carries a stored GUID, minting and writing one if it -// has none yet (a project saved before this feature shipped, or a brand-new -// first save). Returns the effective GUID: the existing one, the freshly minted -// one, or "" for an unsaved project (no .rpp to store ext state into — the same -// gate SetProjExtState/saveToActiveProject already respect on empty path). -// Called from BOTH prime and the Load branch so identity is established the same -// way on every entry to a project (peer-symmetry: no path skips the mint). +// Returns the existing GUID, a freshly minted one, or "" for an unsaved +// project. Called from both prime and the Load branch so no path skips the mint. std::string ensureProjectGuid(void* proj, const std::string& rppPath, const std::string& currentGuid) { if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID @@ -130,11 +107,9 @@ std::string ensureProjectGuid(void* proj, const std::string& rppPath, return minted; } -// Copy the bank folder from oldDir to newDir, non-destructively (copy, do not -// move — see the handoff for the copy-vs-move rationale). Overwrites existing -// files at the destination so a re-save is idempotent. Best-effort: filesystem -// errors are swallowed and reported to the console rather than thrown across the -// REAPER boundary. Returns true if the copy ran (source existed). +// Copy, not move (non-destructive); overwrites existing files at the +// destination so a re-save is idempotent. Best-effort: filesystem errors are +// swallowed and reported to the console. Returns true if the copy ran. bool relocateBankFolder(const std::string& oldBankDir, const std::string& newBankDir) { std::error_code ec; @@ -168,58 +143,37 @@ bool ReaSamplerSession::saveToActiveProject() { if (!proj) return false; // no active project — nothing to persist if (rppPath.empty()) return false; // unsaved project — no .rpp to store into - // Phase B: the whole book (pool as bank-zero + named banks) is authoritative and - // rides in the `banks` key. const std::string banksJson = book_.serialize(); SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtBanksKey, banksJson.c_str()); - // Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty - // value DELETES the key (SDK header ~6288: val NULL or "" deletes the data). This - // realizes retirement concretely — after any save, a formerly-legacy project - // carries `banks` and NO `bank_index`, and going forward the legacy key is never - // written. Cheap and idempotent when the key is already absent. + // Retire the legacy single-bank key: SetProjExtState with an empty value + // deletes it. Idempotent when already absent. SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtIndexKey, ""); - // Additive: the Design-View model rides alongside the banks in its own key. - // Independent write — does not disturb the `banks` blob above. + // Each of the following rides in its own key, independent of `banks`. const std::string viewJson = view_.serialize(); SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtViewKey, viewJson.c_str()); - // Additive: the docked panel's tail setting rides alongside in its own key, so the - // tail choice travels inside the .rpp. Independent write — does not disturb the - // bank_index or view_state above. const std::string tailJson = capture::serializeTailSetting(tail_); SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtTailKey, tailJson.c_str()); - // Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own - // `owned_files` key. Independent write — does not disturb the blobs above. Written - // on EVERY save so a capture's manifest record survives Save / Save-As / reopen, - // and so the manifest and the bank stay in lockstep on disk (both persisted by the - // same saveToActiveProject the capture add-path calls). Uses the channel-derived - // namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too. + // Written on every save so the manifest and the bank stay in lockstep on disk. const std::string ownedJson = owned_.serialize(); SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtOwnedKey, ownedJson.c_str()); - // Phase V (V1/V4): stamp the WRITING version — the build producing this save — under - // the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty - // stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is - // the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as - // Stamped on read-back and stays byte-identical to stable regardless of channel; the - // channel is already carried by the isolated namespace (projExtNamespace) this writes to. + // stampVersion() (not appVersion()) is the numeric triple only, no "-beta" + // suffix, so the stamp is byte-identical to stable regardless of channel + // — the channel is already carried by the isolated namespace. SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtVersionKey, version::stampVersion().c_str()); - // S9: stamp the current bank-generation counter under its own wire-shared key, on the SAME - // seam so the counter and MarkProjectDirty stay paired. The value is whatever - // bumpBankGeneration() advanced it to since the last save (0 if never bumped / pre-S9), so - // every content mutation's own save carries the fresh generation the instrument reads. The - // format is the SHARED pure encoder (instrument::map::formatBankGeneration) so writer and reader agree - // byte-for-byte — a decimal integer. Additive: does not disturb the blobs above. + // Whatever bumpBankGeneration() advanced the counter to since the last + // save (0 if never bumped). Shared encoder so writer/reader agree byte-for-byte. SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtBankGenKey, instrument::map::formatBankGeneration(bankGeneration_).c_str()); @@ -234,11 +188,8 @@ bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { if (!proj) return false; // no active project — nothing to signal if (rppPath.empty()) return false; // unsaved project — no .rpp to store into - // One-shot write of the ingest assignment request under its own key (S8). Independent - // of the book/view/tail blobs — this is a transient signal to the instrument, not - // session state that must ride every save. Uses the channel-derived namespace - // (projExtNamespace) like every sibling key — V4 isolation applies here too, so a beta - // instrument reads only a beta extension's assignment requests. + // One-shot write under its own key: a transient signal to the instrument, + // not session state that rides every save. SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtAssignKey, wire.c_str()); MarkProjectDirty(static_cast(proj)); @@ -247,12 +198,8 @@ bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { namespace { -// Load the Design-View model from a project's view_state key, or return a fresh -// default. An absent/empty key (older project with no view state) yields a -// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful, -// never a crash. Malformed JSON is warned and also falls back to default, mirroring -// the bank's malformed-index handling. The whole model round-trips: modes, -// membership, show-both, snapshots, and active mode all ride inside the one blob. +// Absent/empty key -> default-constructed model, graceful, never a crash. +// Malformed JSON is warned and also falls back to default. ViewModeModel loadViewModel(ReaProject* proj) { if (!proj) return ViewModeModel{}; const std::string viewJson = @@ -266,10 +213,8 @@ ViewModeModel loadViewModel(ReaProject* proj) { return std::move(*loaded); } -// Load the tail setting from a project's tail_setting key, or return the default. An -// absent/empty key (older / never-adjusted project) yields the default setting (None / -// 2 s manual) — graceful, never a crash. Malformed JSON is warned and also falls back -// to default, mirroring the bank's and view's malformed handling. +// Absent/empty key -> default (None / 2 s manual). Malformed JSON warns and +// falls back to default. capture::TailSetting loadTailSetting(ReaProject* proj) { if (!proj) return capture::TailSetting{}; const std::string tailJson = @@ -284,12 +229,9 @@ capture::TailSetting loadTailSetting(ReaProject* proj) { return *loaded; } -// Load the owned-file manifest from a project's owned_files key, or return an empty -// manifest. An absent/empty key (older / never-captured project) yields an empty -// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to -// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then -// sees an empty ownership record and (safely) attributes nothing until the next capture -// rebuilds it — losing the record degrades safety, never correctness. +// Absent/empty key -> empty manifest. Malformed JSON warns and falls back to +// empty; prune then attributes nothing until the next capture rebuilds it — +// degrades safety, never correctness. model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) { if (!proj) return model::OwnedFileManifest{}; const std::string ownedJson = @@ -307,45 +249,28 @@ model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) { } // namespace void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) { - // Raise the load signal for the D4 reapply-on-open glue. loadFromProject is the - // single choke point for every load path (prime, project switch/open, forked- - // sibling load), so setting it here — and NOT on the Save-As branch, which keeps - // the in-memory model as-is — makes the signal fire exactly when a fresh view - // model has been installed and its active mode's visibility needs reapplying. - // main.cpp drains it via consumeLoadSignal() on the same tick. + // loadFromProject is the single choke point for every load path (prime, + // project switch/open, forked-sibling load) — NOT the Save-As branch, + // which keeps the in-memory model as-is. main.cpp drains this via + // consumeLoadSignal() on the same tick. loadPending_ = true; - // The view model is restored on EVERY load path (peer-symmetry with the bank - // reset below): switching to a project with no view state must clear stale - // in-memory state, not inherit the previous project's. D3 restores MODEL STATE - // only — no visibility/processing is applied here (that is D4). + // view_/tail_/owned_ are all restored on EVERY load path: switching to a + // project with no stored state must reset to default, never inherit the + // previous project's. An undo/redo reload must re-read the restored + // values so they match the rolled-back state. view_ = loadViewModel(static_cast(proj)); - - // The tail setting is restored on EVERY load path too (peer-symmetry): switching - // to a project with no stored setting must fall back to the default, not inherit - // the previous project's choice (this REPLACES the old session-carry behavior). tail_ = loadTailSetting(static_cast(proj)); - - // The owned-file manifest is restored on EVERY load path too (peer-symmetry with the - // bank/view/tail resets): switching to a project with no stored manifest must reset - // to empty, not inherit the previous project's ownership record; an undo/redo reload - // (R-B) must re-read the restored manifest so it matches the rolled-back bank state. owned_ = loadOwnedManifest(static_cast(proj)); - // Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry - // with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed - // one as Unknown — both silent, no console warning (a pre-versioning project is not - // an error). getProjExtStateString returns "" for an absent key, which is exactly the - // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default. + // An absent stamp classifies as PreVersioning, a malformed one as Unknown + // — both silent. proj == nullptr -> "" -> default. writingVersion_ = version::classifyWritingVersion( proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtVersionKey) : std::string{}); - // S9: recover the bank-generation counter on EVERY load path (peer-symmetry with - // writingVersion_/tail_/view_ above), so it continues monotonic from the stored value - // rather than resetting to 0 on reopen — a next bump then reads > the stored value. A - // project switch reads THAT project's counter, not the previous one's; an absent/malformed - // stamp (pre-S9 or corrupt) parses to 0 via the SHARED decoder. proj == nullptr -> 0. + // Continues monotonic from the stored value rather than resetting to 0 on + // reopen; absent/malformed parses to 0 via the shared decoder. bankGeneration_ = instrument::map::parseBankGeneration( proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtBankGenKey) : std::string{}); @@ -355,14 +280,9 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi return; } - // Read both possible sources: the authoritative `banks` blob and the retired-but- - // possibly-still-present legacy `bank_index`. The precedence + migration decision - // (`banks` wins; else the legacy index migrates into the pool; else an empty book) - // is pure logic; it is inlined here rather than via BankBook::loadFromPersisted only - // so a malformed `banks` blob can be warned on the console (single parse) — a corrupt - // blob must read as "ignored", not silent loss, mirroring the prior malformed-index - // warning. A malformed `banks` degrades to an empty book and does NOT fall back to - // the stale legacy key (which would resurrect superseded single-bank state). + // `banks` is authoritative when present; a malformed blob degrades to an + // empty book rather than falling back to the stale legacy key (which + // would resurrect superseded single-bank state). const std::string banksJson = getProjExtStateString(proj, projExtNamespace(), kProjExtBanksKey); if (!banksJson.empty()) { @@ -374,29 +294,18 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi book_ = std::move(*loaded); } } else { - // No `banks` yet — fall back to the legacy `bank_index`, migrated into the pool - // by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or- - // empty tail; passing "" for banksJson takes exactly that branch. + // No `banks` yet — migrate the legacy `bank_index` into the pool. const std::string legacyJson = getProjExtStateString(proj, projExtNamespace(), kProjExtIndexKey); book_ = BankBook::loadFromPersisted(std::string{}, legacyJson); } - // L7 slot migration: seed every bank's display-position SlotMap from its index - // insertion order when the loaded blob carried none (a pre-L7 project -> dense, - // gap-free, visually identical on first post-L7 load), and reconcile a partial map - // (drop stale markers, append unmapped samples) for a blob written by an earlier L7 - // build. One-way: once the book is re-saved the reconciled slot data is authoritative. - // Idempotent, so a fresh empty book is a cheap no-op. + // Seed each bank's display-position SlotMap from insertion order when the + // loaded blob carried none, and reconcile a partial map. Idempotent. book_.reconcileSlots(); - // Project-relative resolution is a READ-time concern: every BankModel in the book - // stores only relative paths (invariant, enforced per-bank at add()), and consumers - // (M5 panel, M6 insert) resolve each entry against the CURRENT project dir via - // resolveBankFile(projectDir, relativePath). We do NOT rewrite stored paths to - // absolute here — that would break the relative-only invariant and travel-with-.rpp. - // projectDir is threaded through for those consumers; nothing to do at load time - // beyond replacing the in-memory book. + // Paths stay relative (read-time resolution is the consumers' job); + // nothing to do here beyond replacing the in-memory book. (void)projectDir; } diff --git a/src/shell/persist/ext_state_io.h b/src/shell/persist/ext_state_io.h index ab5a389..4f6f9be 100644 --- a/src/shell/persist/ext_state_io.h +++ b/src/shell/persist/ext_state_io.h @@ -1,59 +1,40 @@ #pragma once -// ext_state_io — the ext-state ↔ JSON serialization half of the persist seam -// (Q-W5 split of the former persist god-TU; session.h holds the ReaSamplerSession -// lifecycle, prune_fs.cpp the prune scan + the single file-deletion authority). +// ext_state_io — the ext-state <-> JSON serialization half of the persist seam. // This header owns the persist-side key spellings and the channel-derived -// namespace accessor; the TU (ext_state_io.cpp) implements the session's -// save/load/assignment-request bridge plus the GUID minting and bank-folder -// relocation helpers the poll executes. +// namespace accessor; ext_state_io.cpp implements the session's save/load/ +// assignment-request bridge plus GUID minting and bank-folder relocation. // -// The ext-state namespace + the WIRE-SHARED key names are the contract between this -// extension (writer) and the VST3 instrument (reader), so they live in ext_keys.h -// (pure, REAPER-free) and are included here — not duplicated. The namespace is -// CHANNEL-DERIVED (Phase V, V4): ext_keys.h's kProjExtNamespace / this projExtNamespace() -// both delegate to app_version's extStateNamespace() — "reasampler" on stable (byte- -// identical to the pre-V4 build) or "reasampler_beta" on the isolated beta build. Both -// artifacts read the ONE app_version symbol, so the instrument reads exactly the namespace -// the extension writes, per channel. Beta reads/writes ONLY its own namespace — a project -// saved by stable shows empty/default state in beta and vice versa; that isolation is the -// accepted V4 safety property (no cross-namespace read, migration, or fallback), not a bug. -// The per-key semantics persist relies on (spellings owned by ext_keys.h): -// * kProjExtBanksKey : the whole serialized BankBook (pool + named banks). -// AUTHORITATIVE going forward; the VST reads this key to see the live bank. -// * kProjExtIndexKey : RETIRED legacy single-bank key. No longer WRITTEN (cleared -// on save); READ once on load to migrate a legacy project into the pool. -// * kProjExtViewKey : the Design-View ViewModeModel JSON. -// * kProjExtTailKey : the docked panel's TailSetting JSON. -// * kProjExtGuidKey : the per-project minted GUID (content-based identity; poll() -// tells a Save-As from a recycled-pointer project switch by it). -// All are FOREVER-STABLE once shipped: changing any strands every already-saved -// project's stored state under that key. +// The namespace + wire-shared key names are the contract with the VST3 +// instrument; they live in ext_keys.h (pure, REAPER-free), included here, not +// duplicated. Channel-derived: "reasampler" on stable, "reasampler_beta" on +// beta — a project saved by stable shows empty/default state in beta and vice +// versa; that isolation is deliberate. +// +// Per-key semantics (spellings owned by ext_keys.h): kProjExtBanksKey is the +// whole serialized BankBook, authoritative; kProjExtIndexKey is the retired +// legacy single-bank key (read once to migrate); kProjExtViewKey/TailKey are +// the Design-View and tail JSON; kProjExtGuidKey is the per-project minted +// GUID poll() uses to tell Save-As from a recycled-pointer switch. All are +// FOREVER-STABLE once shipped. #include "core/version/app_version.h" #include "ext_keys.h" namespace reasampler { -// The accessor form of the namespace: ext_keys.h's kProjExtNamespace is the value; this -// is the const char* the SetProjExtState/GetProjExtState calls pass. Kept as an -// accessor (not a literal) because the string is channel-derived at build time. +// Accessor, not a literal, because the string is channel-derived at build time. inline const char* projExtNamespace() { return version::extStateNamespace().c_str(); } -// The two EXTENSION-ONLY keys — NOT part of the VST wire contract (the instrument -// reads only banks/view/tail/guid), so they stay here rather than in ext_keys.h: -// -// owned_files — the owned-file manifest JSON (project-relative files the capture path -// itself created; Phase B B-cap seam, consumed by Phase R prune to tell the bank system's -// own orphans from hand-dropped files). A SIBLING key alongside banks/view/tail — NOT -// folded into `banks`, so it stays decoupled from membership. FOREVER-STABLE: changing it -// strands every saved project's ownership record (prune falls back to an empty manifest — -// graceful, but the attribution safety net is lost until the next capture rebuilds it). +// Extension-only keys — not part of the VST wire contract, so they live here +// rather than in ext_keys.h. + +// Project-relative files the capture path itself created, consumed by prune +// to tell the bank system's own orphans from hand-dropped files. A sibling +// key, not folded into `banks`. FOREVER-STABLE. inline constexpr const char* kProjExtOwnedKey = "owned_files"; -// version — the ReaSampler version that last WROTE this project (Phase V, V1). Written on -// every save, so every saved .rpp records which build produced its state — the seam a -// future within-channel forward migration keys off. An absent key is the explicit -// pre-versioning case, read silently, never an error. FOREVER-STABLE key string. +// The ReaSampler version that last wrote this project. An absent key is the +// pre-versioning case, read silently. FOREVER-STABLE. inline constexpr const char* kProjExtVersionKey = "version"; } // namespace reasampler diff --git a/src/shell/persist/persist_internal.h b/src/shell/persist/persist_internal.h index f574387..c98345a 100644 --- a/src/shell/persist/persist_internal.h +++ b/src/shell/persist/persist_internal.h @@ -1,14 +1,9 @@ -// persist_internal.h — INTERNAL shared helpers for the persist TU family (Q-W5: -// session / ext_state_io / prune_fs, split out of the former persist.cpp god-TU). -// Included ONLY by those three TUs — never a public seam (mirror of the panel's -// panel_state.h / the editor's editor_internal.h internal-seam precedent). Holds the -// former anonymous-namespace helpers that more than one split TU needs; every -// definition lives in ext_state_io.cpp (they are all ext-state / GUID / path / folder -// machinery). Behavior-identical to the pre-split definitions. +// persist_internal.h — internal shared helpers for the persist TU family +// (session / ext_state_io / prune_fs). Included only by those three TUs, never +// a public seam. Every definition lives in ext_state_io.cpp. // -// REAPER-FREE HEADER: the project handle crosses this seam as the same opaque void* -// the public session header already uses, so no SDK type leaks; the .cpps cast at -// the API boundary. +// REAPER-free header: the project handle crosses this seam as the same opaque +// void* the public session header uses, so no SDK type leaks. #pragma once @@ -29,8 +24,7 @@ std::string projectDirOf(const std::string& rppPath); // Growing GetProjExtState read for `key` in namespace `ns` against `proj`. Returns // "" when the key is absent (a valid empty bank, not an error) and warns on the // console for a value exceeding the 16 MB read ceiling (unreadable whole, ignored). -// The retry policy itself is the shared pure wire::readProjExtStateGrowing -// (T2-04; rehomed to core/wire in Q-W6); this wrapper binds the REAPER call + persist's fold. +// Binds wire::readProjExtStateGrowing (the shared retry policy) to the REAPER call. std::string getProjExtStateString(void* proj, const char* ns, const char* key); // The GUID we mint per project, formatted "{XXXXXXXX-....}" by guidToString. diff --git a/src/shell/persist/prune_fs.cpp b/src/shell/persist/prune_fs.cpp index f5c7f27..783164b 100644 --- a/src/shell/persist/prune_fs.cpp +++ b/src/shell/persist/prune_fs.cpp @@ -1,21 +1,17 @@ -// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler -// (Q-W5 split of the former persist.cpp; see session.h for the TU map). +// prune_fs.cpp — the prune scan + THE SINGLE FILE-DELETION AUTHORITY in ReaSampler. // -// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove on -// SWELL platforms) is the ONLY code in the system that deletes USER files — the sole -// deletion authority over the bank folder's bytes (the R3 prune; shells removing a -// transient scratch file they themselves just created, e.g. the drop path's temp -// .vstpreset, are self-cleanup, not authority over user data). It is deliberately -// file-local (anonymous namespace): nothing outside this TU can reach it. The Q-W5 -// split CONCENTRATES the deletion authority here — it must never -// spread (CONTEXT.md §Phase Q deletion-authority isolation; -// docs/product/code-organization.md §7). The safety-critical "which files are +// deleteOrphanFile below (SHFileOperationW on Windows, std::filesystem::remove +// on SWELL platforms) is the ONLY code in the system that deletes USER files — +// the sole deletion authority over the bank folder's bytes (a shell removing a +// transient scratch file it just created, e.g. the drop path's temp +// .vstpreset, is self-cleanup, not authority over user data). Deliberately +// file-local (anonymous namespace): nothing outside this TU can reach it, and +// this concentration must never spread. The safety-critical "which files are // orphans" decision stays in the pure core (prune_reconcile); this TU only -// enumerates, resolves, stats, and — after the R3 confirm — executes. +// enumerates, resolves, stats, and — after the confirm — executes. // -// Compiled into the reaper_reasampler MODULE. REAPER-facing only through the -// persist_detail helpers (active-project read) and usage_scan (the pS-usage -// instance-hold reads); this TU itself calls no REAPER API directly. +// Compiled into the reaper_reasampler module. REAPER-facing only through the +// persist_detail helpers and usage_scan; this TU itself calls no REAPER API directly. #include #include @@ -25,12 +21,11 @@ #include #include -// Move-to-trash surface (fork R-C, trash-preferred). On Windows the Recycle Bin is -// reached via SHFileOperationW + FOF_ALLOWUNDO (verified against the Windows SDK -// shellapi.h: SHFILEOPSTRUCTW { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, -// FO_DELETE=0x3, FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on the SWELL -// (macOS/Linux) side of this codebase, so those platforms fall back to unlink behind the -// R3 dry-run/confirm guardrail — see deleteOrphanFile below for the per-platform routing. +// Move-to-trash surface, trash-preferred. Windows reaches the Recycle Bin via +// SHFileOperationW + FOF_ALLOWUNDO (verified against shellapi.h: SHFILEOPSTRUCTW +// { hwnd, wFunc, pFrom(double-NUL list), pTo, fFlags, ... }, FO_DELETE=0x3, +// FOF_ALLOWUNDO=0x40). No portable move-to-trash exists on SWELL (macOS/Linux), +// so those platforms fall back to unlink — see deleteOrphanFile below. #ifdef _WIN32 #include #include @@ -38,7 +33,7 @@ #include "shell/persist/persist_internal.h" #include "shell/persist/session.h" -#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths (pS-usage: instance holds join `referenced`) +#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths — instance holds join `referenced` #include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder #include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies @@ -52,31 +47,24 @@ namespace fs = std::filesystem; using persist_detail::projectDirOf; using persist_detail::readActiveProject; -// The dry-run file-list display cap: the orphan COUNT and reclaimed SIZE are always -// exact (tallied over the full orphan set), but the enumerated file list handed to the -// console is clipped to this many entries so a project with thousands of orphans does -// not flood the report. PruneReport::truncated flags the clip. R3's confirm surface can -// choose its own presentation; this is purely the Wave-2 dry-run readout ceiling. +// The dry-run file-list display cap: count and size are always exact +// (tallied over the full orphan set), but the enumerated list handed to the +// console is clipped so a project with thousands of orphans does not flood +// the report. PruneReport::truncated flags the clip. constexpr std::size_t kPruneListDisplayCap = 64; -// A fresh enumerate + pure-core prune compute for the active project. Shared by the -// dry-run report (pruneDryRun), the full-set query (pruneOrphanSet), and the deletion -// (pruneReclaim) so all three agree on ONE resolution + enumeration + set-algebra path -// (no divergence between what is shown and what is deleted). REAPER-facing (resolves the -// active project, enumerates the folder) but writes nothing. +// A fresh enumerate + pure-core prune compute for the active project. Shared +// by the dry-run report, the full-set query, and the deletion so all three +// agree on one resolution + enumeration + set-algebra path — no divergence +// between what is shown and what is deleted. REAPER-facing but writes nothing. // -// * bankDirAbs — the resolved CURRENT bank folder (absolute, forward-slashed). Empty -// when there is no active/saved project, no project dir, or no folder on -// disk yet -> the caller treats an empty dir as "nothing to reclaim". -// * orphans — the FULL orphan set (owned ∩ present) − referenced, in enumeration -// order, untruncated. The pure core decides; this only supplies inputs. -// * sizeByRel — per-orphan-relative on-disk byte size (0 when it could not be stat'd). -// * abortedUnreadableUsage — true iff a present rsusage_* instance-usage record could -// not be read/decoded (pS-usage fail-safe): `orphans` is left EMPTY — -// the prune must halt rather than proceed with degraded protection. -// An empty orphan set is itself the delete-side guarantee (every -// consumer of this scan deletes at most `orphans ∩ ...`), the flag is -// what lets the action TELL the user instead of claiming "no orphans". +// * bankDirAbs — the resolved current bank folder. Empty when there is no +// active/saved project, no project dir, or no folder yet. +// * orphans — the full orphan set, untruncated. The pure core decides. +// * sizeByRel — per-orphan on-disk byte size (0 when it could not be stat'd). +// * abortedUnreadableUsage — true iff a present rsusage_* record could not +// be read/decoded: `orphans` is left EMPTY, the prune must +// halt rather than proceed with degraded protection. struct PruneScan { std::string bankDirAbs; std::vector orphans; @@ -95,10 +83,8 @@ PruneScan scanPruneOrphans(const BankBook& book, void* proj = readActiveProject(rppPath); if (!proj || rppPath.empty()) return scan; // no active/saved project -> empty scan - // Resolve the CURRENT bank folder the same way the index does (M4): project dir of - // the live .rpp + the fixed bank subfolder. Never a stored absolute path, so a - // Save-As relocation is followed automatically. resolveBankFile is the shared M4 - // arithmetic; feeding it the bank subfolder as the "relative path" yields the folder. + // Resolve the current bank folder the same way the index does — never a + // stored absolute path, so a Save-As relocation is followed automatically. const std::string projectDir = projectDirOf(rppPath); const std::string bankDir = capture::resolveBankFile(projectDir, capture::kBankSubfolder); @@ -109,15 +95,11 @@ PruneScan scanPruneOrphans(const BankBook& book, return scan; // no bank folder captured yet -> nothing to reclaim } - // Enumerate the folder into project-relative index-spelled paths, spelled the SAME - // way the capture path spelled them (bankRelativeForName == deriveBankPaths's - // convention) so the pure core's exact-string match lines up with referencedPaths() - // and the manifest. Non-recursive: the bank folder is flat (capture writes files - // directly here); skip any subdirectory. Size is stat'd here and cached by relative - // path so the report's byte tally reuses the same on-disk read. - // Manual iterator form (it.increment(ec)) keeps the loop non-throwing: a mid-iteration - // failure (file removed, permission flip) breaks out with a best-effort partial list - // rather than propagating std::filesystem_error across REAPER's C ABI. + // Enumerate into project-relative paths spelled the SAME way the capture + // path spells them, so the pure core's exact-string match lines up with + // referencedPaths() and the manifest. Non-recursive: the bank folder is + // flat. Manual iterator form (it.increment(ec)) keeps the loop + // non-throwing on a mid-iteration failure. std::vector present; fs::directory_iterator it(bankDir, ec); for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) { @@ -133,24 +115,20 @@ PruneScan scanPruneOrphans(const BankBook& book, scan.sizeByRel[rel] = sz_ec ? 0 : static_cast(sz); } - // The decision lives in the pure core — read-only inputs from the book and manifest. - // referencedPaths() unions across the whole book (pool included); owned().paths() is - // the manifest set. pS-usage: the referenced set additionally unions every LIVE - // ReaSampler 9000 instance's held captures (usage_scan reads the per-instance - // rsusage_* records + the live FX enumeration; sample_usage decides liveness, - // including the protect-all net when zero instances were identified) — a capture - // any live instance holds can NEVER be an orphan, even when its bank entry was - // deleted while the instance kept its ref. liveInstanceHeldPaths is READ-ONLY, - // preserving this scan's no-write contract. This shell only enumerates, resolves, - // and stats. + // The decision lives in the pure core — read-only inputs from the book and + // manifest. referencedPaths() unions across the whole book; the referenced + // set additionally unions every LIVE ReaSampler 9000 instance's held + // captures (usage_scan + sample_usage decide liveness) — a capture any + // live instance holds can never be an orphan, even if its bank entry was + // deleted while the instance kept its ref. liveInstanceHeldPaths is + // read-only; this shell only enumerates, resolves, and stats. scan.bankDirAbs = bankDir; const UsageScanResult usage = liveInstanceHeldPaths(proj); if (usage.abortPrune) { - // FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, so the - // protected set is unknowable. Compute NO orphans — every downstream consumer - // (dry-run report, confirm set, fresh-recompute delete plan) then deletes - // nothing. The flag + key names surface the reason so the action can name each - // offending key for operator recovery. + // FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded, + // so the protected set is unknowable. Compute NO orphans — every + // downstream consumer then deletes nothing. The key names let the + // action tell the user which keys to recover. scan.abortedUnreadableUsage = true; scan.offendingUsageKeys = usage.offendingKeys; return scan; @@ -162,38 +140,31 @@ PruneScan scanPruneOrphans(const BankBook& book, return scan; } -// Deletes ONE orphan file, trash-preferred (fork R-C, settled). Returns true iff the -// file was deleted BY THIS CALL (reclaimed here). Returns false for two distinct cases: -// * `outAlreadyAbsent` set true — the file was already gone before we touched it; -// the caller folds this into the stale/staleness tally, NOT reclaimedCount. -// * `outAlreadyAbsent` left false — a real delete failure (locked, conversion error); -// the caller folds this into skippedCount. -// `absPath` is the resolved absolute path (forward-slashed). NON-THROWING: no exception -// may cross the C ABI. +// Deletes ONE orphan file, trash-preferred. Returns true iff deleted by this +// call. Returns false with `outAlreadyAbsent` set when the file was already +// gone (caller folds into staleness, not reclaimedCount); false with it unset +// on a real delete failure (locked, conversion error — folds into +// skippedCount). `absPath` is the resolved absolute path. Non-throwing: no +// exception may cross the C ABI. // -// Per-platform routing: -// * Windows — SHFileOperationW(FO_DELETE, pFrom=, FOF_ALLOWUNDO | -// FOF_NOCONFIRMATION | FOF_SILENT | FOF_NOERRORUI). FOF_ALLOWUNDO routes to the -// Recycle Bin (recoverable); the no-UI flags suppress REAPER-blocking dialogs (our -// own confirm already happened). Verified against shellapi.h. `outUsedTrash` set true. -// * Other (SWELL: macOS/Linux) — no portable move-to-trash surface is available in this -// codebase, so fall back to std::filesystem::remove (hard unlink) behind the R3 -// confirm guardrail. `outUsedTrash` left as-is (false). +// Windows routes through SHFileOperationW + FOF_ALLOWUNDO (Recycle Bin, +// recoverable); the no-UI flags suppress REAPER-blocking dialogs since our own +// confirm already happened. Other platforms (SWELL: macOS/Linux) have no +// portable move-to-trash surface, so they fall back to std::filesystem::remove +// (hard unlink) behind the confirm guardrail. bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, bool& outAlreadyAbsent) { #ifdef _WIN32 - // Convert forward-slashed UTF-8 to a back-slashed, double-NUL-terminated wide string. - // SHFileOperation's pFrom is a list; a single path still needs the extra terminating - // NUL. Backslashes are required (shell APIs reject forward slashes in some cases). + // Back-slashed, double-NUL-terminated wide string: SHFileOperation's + // pFrom is a list (needs the extra terminating NUL) and rejects forward + // slashes in some cases. std::string win = absPath; for (char& c : win) if (c == '/') c = '\\'; const int wlen = MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, nullptr, 0); - if (wlen <= 0) return false; // conversion failed -> real skip (outAlreadyAbsent stays false) + if (wlen <= 0) return false; // conversion failed -> real skip std::vector wbuf(static_cast(wlen) + 1, L'\0'); // +1 for list NUL MultiByteToWideChar(CP_UTF8, 0, win.c_str(), -1, wbuf.data(), wlen); - // wbuf now holds the path + its NUL at [wlen-1]; the extra trailing L'\0' at [wlen] - // makes it the double-NUL-terminated single-element list SHFileOperation wants. SHFILEOPSTRUCTW op{}; op.hwnd = nullptr; @@ -207,22 +178,20 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, outUsedTrash = true; return true; // deleted this call -> reclaimed } - // SHFileOperation failed (e.g. file already gone yields a nonzero code on some - // versions, or a lock). Distinguish "already absent" from a real failure so the - // caller can tally them separately (absent -> staleness skip; failure -> locked skip). + // Distinguish "already absent" (nonzero return on some REAPER versions + // for a vanished file) from a real failure so the caller can tally separately. std::error_code ec; if (!fs::exists(absPath, ec)) { - outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim + outAlreadyAbsent = true; } return false; #else - // No portable trash surface on SWELL platforms -> hard unlink behind the confirm. + // No portable trash surface on SWELL platforms -> hard unlink. std::error_code ec; const bool removed = fs::remove(absPath, ec); - if (removed) return true; // deleted this call -> reclaimed - if (ec) return false; // a real failure (locked / permission) -> skip - // remove returned false with no error == the file did not exist -> already gone. - outAlreadyAbsent = true; // vanished between scan and delete -> staleness, not reclaim + if (removed) return true; + if (ec) return false; // real failure (locked/permission) -> skip + outAlreadyAbsent = true; // no error, no removal -> already gone return false; #endif } @@ -231,53 +200,47 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash, reclaim::PruneReport ReaSamplerSession::pruneDryRun() const { const PruneScan scan = scanPruneOrphans(book_, owned_); - // buildPruneReport tallies count / byte-sum / display-truncation — no report logic - // re-implemented here. An empty scan (no project / no folder) yields a zero report. reclaim::PruneReport report = reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap); - // pS-usage fail-safe: surface the unreadable-record abort so the action halts with - // an explicit message instead of reporting "no orphaned files" (the count IS zero — - // the scan computed nothing — but the user must know the prune refused to run). - // The offending key names propagate so the action can name each one for recovery. + // Surface the unreadable-usage abort so the action halts with an explicit + // message instead of reporting "no orphaned files" — the count IS zero, + // but the user must know the prune refused to run. report.abortedUnreadableUsage = scan.abortedUnreadableUsage; report.offendingUsageKeys = scan.offendingUsageKeys; return report; } std::vector ReaSamplerSession::pruneOrphanSet() const { - return scanPruneOrphans(book_, owned_).orphans; // FULL set, untruncated + return scanPruneOrphans(book_, owned_).orphans; // full set, untruncated } reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( const std::vector& confirmed) const { reclaim::PruneDeletionResult result; - // Re-enumerate + run the pure core FRESH (never a stale set): the deletion targets - // exactly `confirmed ∩ freshOrphans` (pruneDeletePlan). A file that vanished or became - // referenced between confirm and delete drops out of freshOrphans and is skipped; a - // newly-appeared orphan not in `confirmed` is never swept without its own confirm. - // Because freshOrphans is itself a pure-core output, the plan can contain NO referenced - // and NO hand-dropped file — the R-C/R-D safety survives the recompute. - // pS-usage: if THIS fresh scan hits an unreadable rsusage_* record it aborts with an - // EMPTY orphan set, so the plan below intersects to empty and nothing is deleted — - // the fail-safe holds even in the confirm→delete window, with no extra branch here. + // Re-enumerate + run the pure core FRESH (never a stale set): deletion + // targets exactly `confirmed ∩ freshOrphans`, so a file that vanished or + // became referenced between confirm and delete is skipped, and a newly- + // appeared orphan not in `confirmed` is never swept. If this fresh scan + // hits an unreadable usage record it aborts with an EMPTY orphan set, so + // the plan below intersects to empty and nothing is deleted — the + // fail-safe holds even in the confirm-to-delete window. const PruneScan scan = scanPruneOrphans(book_, owned_); if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing const std::vector plan = reclaim::pruneDeletePlan(confirmed, scan.orphans); - // Staleness skip count: entries the user confirmed that are no longer fresh orphans - // (vanished or became referenced between confirm and delete). pruneDeletePlan already - // de-dups confirmed internally, so compute the unique-confirmed size to avoid counting - // de-duplicated entries as stale — that would be dishonest. + // Staleness skip count: confirmed entries no longer fresh orphans. + // pruneDeletePlan de-dups confirmed internally, so compare against the + // unique-confirmed size to avoid counting de-duped entries as stale. const std::size_t uniqueConfirmedCount = std::unordered_set(confirmed.begin(), confirmed.end()).size(); result.skippedCount += uniqueConfirmedCount - plan.size(); for (const std::string& rel : plan) { - // Reconstruct the absolute path from the resolved bank dir + the entry's file name. - // rel is index-spelled "/"; the name is the tail after '/'. + // rel is index-spelled "/"; reconstruct the + // absolute path from the resolved bank dir + the tail after '/'. const std::string::size_type slash = rel.find_last_of('/'); const std::string name = (slash == std::string::npos) ? rel : rel.substr(slash + 1); if (name.empty()) { ++result.skippedCount; continue; } @@ -291,9 +254,7 @@ reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim( ++result.reclaimedCount; result.reclaimedBytes += bytes; } else if (alreadyAbsent) { - // File vanished between plan and delete — treat as staleness, same as the - // confirm→plan gap above. Does NOT count as reclaimed (we didn't delete it). - ++result.skippedCount; + ++result.skippedCount; // vanished between plan and delete -> staleness } else { ++result.skippedCount; // locked / conversion failure -> recorded, not thrown } diff --git a/src/shell/persist/session.cpp b/src/shell/persist/session.cpp index f84b664..0d9bca7 100644 --- a/src/shell/persist/session.cpp +++ b/src/shell/persist/session.cpp @@ -1,65 +1,25 @@ -// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (Q-W5 -// split of the former persist.cpp; see session.h for the TU map): the poll-driven -// identity-transition detection and the deferred undo/redo reload drain. +// session.cpp — the ReaSamplerSession lifecycle half of the persist seam (see +// session.h for the identity-transition design and the TU map). // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API // pointers; here they are extern (CLAUDE.md §contract). // -// PROJECT-LOAD / SAVE-AS DETECTION (chosen mechanism): -// Driven by REAPER's "timer" register (main.cpp). Each poll() reads the active -// project (EnumProjects(-1)), its .rpp path, and the GUID we store in its ext -// state. Identity is layered GUID-PRIMARY, with the ReaProject* pointer as the -// secondary disambiguator (classifyProjectTransition owns the exact order): -// * different stored GUID -> a different project of record -> LOAD its index; -// NEVER relocate. Catches pointer RECYCLING (REAPER reuses a closed project's -// address, so a reopened/new project can present the previous pointer with a -// different GUID), new/unsaved<->saved, and switching between distinct saved -// projects. -// * SAME GUID, DIFFERENT object -> a forked sibling that copied our GUID via -// Save-As -> LOAD its index; NEVER relocate; re-GUID it so the siblings -// diverge going forward. -// * SAME GUID, SAME object, .rpp path changed -> genuine Save-As to a new -// location -> relocate the bank folder from the old dir to the new one, then -// re-GUID. -// Why GUID-primary (W12 fix): this layers the two prior designs. M4 (GUID-only) -// broke Save-As forks — Save-As copies the whole .rpp incl. our stored GUID, so a -// fork and its parent share a GUID on disk; switching between them read as a -// Save-As and clobbered a bank. W10 (pointer-primary, GUID voided) broke pointer -// RECYCLING — a reopened/new project reusing the previous project's address read -// as NoOp/SaveAsRelocate and the bank never reloaded. Checking the GUID first -// catches recycling; the pointer then separates a fork (same GUID, different -// object -> Load) from a Save-As (same GUID, same object, new path -> relocate). -// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` -// bool (poll() computes `proj == lastProject_`) so the decision stays REAPER-free -// and testable; poll() executes the verdict. +// classifyProjectTransition (pure, capture_paths) takes a `sameProjectObject` +// bool so the decision stays REAPER-free and testable; poll() executes the +// verdict. REAPER exposes no stable per-project GUID, so we mint one (genGuid/ +// guidToString) under kProjExtGuidKey; Save-As copies the whole .rpp including +// our ext state, so the new project initially shares the old GUID, and poll() +// re-GUIDs it after relocating (or on the forked-sibling Load branch). // -// REAPER exposes no stable per-project GUID (GetSetProjectInfo_String has no -// PROJECT_GUID desc; GetProjectStateChangeCount is a session-local counter, not -// a cross-open identity), so we MINT one with genGuid/guidToString and store it -// under kProjExtGuidKey (ext_state_io.cpp owns the minting helpers). On Save-As -// REAPER copies the whole .rpp incl. our ext state, so the new project initially -// shares the old GUID; poll() re-GUIDs it (after relocating, or on the forked- -// sibling Load branch) so identities diverge. -// -// Rationale for the timer: the brief mandates ext-state storage (rules out the -// projectconfig .rpp-line hook for STORAGE), and the timer composes cleanly with -// ext-state while covering identity-transition load + Save-As detection in one -// place. -// -// DIVISION OF LABOUR (R-B undo): -// * Identity-transition poll (this file, classifyProjectTransition) owns -// open / tab-switch / new / forked-sibling / Save-As-relocation — every case -// where the project OF RECORD changes. -// * The `projectconfig` hook (main.cpp registers project_config_extension_t; -// BeginLoadProjectState with isUndo) owns UNDO/REDO — where the project -// identity is unchanged but its ext state rolled back/forward on disk. The -// identity poll sees NoOp there and would never re-read ext state, so the hook -// requests a reload (requestReload) that poll() drains on the next tick, once -// REAPER has restored the block. See requestReload / the poll drain. -// The hook fires on undo AND redo (isUndo true for both), and on normal open -// (isUndo false) — but we set the reload flag ONLY for isUndo, so a normal open -// flows solely through the identity-transition Load path and never double-loads. +// Division of labour for undo/redo: the identity-transition poll (this file) +// owns open/tab-switch/new/forked-sibling/Save-As. The `projectconfig` hook +// (main.cpp, BeginLoadProjectState with isUndo) owns undo/redo, where identity +// is unchanged but ext state rolled back/forward on disk — the identity poll +// would see NoOp there, so the hook requests a reload that poll() drains next +// tick, once REAPER has restored the block. The hook fires on +// undo, redo, AND normal open, but the reload flag is set only for isUndo, so +// a normal open never double-loads. #include "shell/persist/session.h" @@ -91,9 +51,8 @@ bool ReaSamplerSession::consumeLoadSignal() { } void ReaSamplerSession::requestReload() { - // Set-only; poll() drains it on the next tick (see the poll() drain block for why - // the read is deferred past the projectconfig callback). Cheap and idempotent — - // multiple undo/redo callbacks before the next tick collapse to one reload. + // Set-only; poll() drains it next tick. Idempotent — multiple undo/redo + // callbacks before the next tick collapse to one reload. reloadRequested_ = true; } @@ -116,19 +75,13 @@ void ReaSamplerSession::poll() { return; } - // Undo/redo reload (owner: the projectconfig hook, NOT the identity classifier - // below). An undo/redo keeps the SAME project identity — same ReaProject*, GUID, - // and .rpp path — so classifyProjectTransition would return NoOp and never re-read - // ext state, leaving book_/view_ stale after the on-disk ext state rolled back. - // The projectconfig BeginLoadProjectState callback (isUndo) raised reloadRequested_ - // one or more ticks ago; by NOW REAPER has finished restoring the project's - // block, so GetProjExtState returns the POST-undo value. Reload from the - // current active project and identity-adopt it (no relocation — the path is - // unchanged), then return. loadFromProject raises loadPending_, so the existing - // consumeLoadSignal() glue re-baselines the panel detector and reapplies the active - // mode; bankPanelRefresh's fingerprint pass then repaints the restored book. This is - // the ONLY undo/redo reload path — the timer never polls ext-state CONTENT to detect - // an undo (Daniel's directive: the hook drives it, not a poll heuristic). + // Undo/redo reload, owned by the projectconfig hook, not the identity + // classifier below: an undo/redo keeps the same project identity, so + // classifyProjectTransition would return NoOp and never re-read ext + // state. By now REAPER has finished restoring the block, so + // GetProjExtState returns the post-undo value. Reload and identity-adopt + // (no relocation — path unchanged). This is the ONLY undo/redo reload + // path — the timer never polls ext-state content to detect an undo. if (reloadRequested_) { reloadRequested_ = false; loadFromProject(proj, projectDirOf(rppPath)); @@ -150,20 +103,14 @@ void ReaSamplerSession::poll() { return; case capture::ProjectTransition::Load: { - // A different project of record is active (open / tab switch / new / - // reopened / recycled pointer / forked sibling). Load ITS index; never - // relocate. + // A different project of record is active. Load its index; never relocate. // - // Forked-sibling divergence: gate on `!sameProjectObject` so this fires - // ONLY for a step-2 Load (same GUID, different object) — a Save-As fork - // that copied our GUID and never re-saved (its fresh GUID was runtime- - // only on the sibling we came from). A recycled-pointer Load (step 1: - // currentGuid != lastGuid_) must NOT re-GUID — it is already a distinct - // identity. currentGuid == lastGuid_ can only hold here when step 1 did - // NOT fire, i.e. this is the fork case; the explicit !sameProjectObject - // makes that intent load-bearing rather than incidental. Do this BEFORE - // loadFromProject reads the index (order is irrelevant — GUID and - // bank_index are distinct keys — but self-contained is clearest). + // Forked-sibling re-GUID: gate on `!sameProjectObject` so this fires + // only for the fork case (same GUID, different object) — a Save-As + // fork that copied our GUID and never re-saved. A recycled-pointer + // Load (currentGuid != lastGuid_) must NOT re-GUID — it is already + // a distinct identity; currentGuid == lastGuid_ can only hold here + // when that case did not fire. if (proj && !sameProjectObject && !currentGuid.empty() && currentGuid == lastGuid_ && !rppPath.empty()) { const std::string fresh = genProjectGuidString(); @@ -186,12 +133,10 @@ void ReaSamplerSession::poll() { } case capture::ProjectTransition::SaveAsRelocate: { - // SAME project object + new .rpp path: a genuine Save-As (the pointer - // proves it — a fork tab-switch is a DIFFERENT object and took the Load - // branch above). Relocate the bank folder from the old dir to the new - // one so the wavs sit under the new .rpp and the index's relative paths - // still resolve. Keep the in-memory bank as-is (Save-As copied our ext - // state, the relative paths are unchanged) — do NOT reload. + // Same project object, new .rpp path: a genuine Save-As. Relocate + // the bank folder so the wavs sit under the new .rpp and the + // index's relative paths still resolve. Keep the in-memory bank + // as-is (Save-As copied our ext state) — do NOT reload. const std::string oldDir = projectDirOf(lastRppPath_); const std::string newDir = projectDirOf(rppPath); const capture::BankRelocation plan = @@ -200,11 +145,9 @@ void ReaSamplerSession::poll() { relocateBankFolder(plan.oldBankDir, plan.newBankDir); } - // Save-As duplicated our ext state, so the new project B currently - // shares A's GUID. Mint a FRESH GUID for B and write it, so A and B - // no longer collide on identity when reopened later. Adopt the fresh - // GUID as our last-seen identity. Mark dirty so the fresh GUID flushes - // to the new .rpp on the next normal save / close-prompt. + // Save-As duplicated our ext state, so the new project shares the + // old GUID; mint a fresh one and mark dirty so it flushes on the + // next save, and A/B no longer collide on identity when reopened. const std::string fresh = genProjectGuidString(); if (proj) { SetProjExtState(static_cast(proj), projExtNamespace(), diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h index 0bec588..6267160 100644 --- a/src/shell/persist/session.h +++ b/src/shell/persist/session.h @@ -1,36 +1,23 @@ #pragma once -// session — the ReaSamplerSession lifecycle owner of the persist seam (Q-W5 split of -// the former persist god-TU; CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & -// paths). One class, three implementation TUs by responsibility: +// session — the ReaSamplerSession lifecycle owner of the persist seam. One +// class, three implementation TUs by responsibility: +// * session.cpp — poll() identity-transition detection (load / Save-As / +// forked sibling / recycled pointer) + the deferred undo/redo reload drain. +// * ext_state_io.cpp — save/load/writeAssignmentRequest: the ext-state <-> +// JSON bridge, GUID minting, bank-folder relocation (see ext_state_io.h). +// * prune_fs.cpp — pruneDryRun/pruneOrphanSet/pruneReclaim: the prune +// scan and THE SINGLE FILE-DELETION AUTHORITY over user files in the bank +// folder. Nothing else in the system deletes bank-folder bytes (a shell's +// self-cleanup of its own transient scratch file is not this authority). // -// * session.cpp — poll() (identity-transition detection: load / Save-As / -// forked sibling / recycled pointer) + the deferred undo/redo reload drain -// (requestReload, raised by main.cpp's projectconfig BeginLoadProjectState hook) -// + the D4 load signal. -// * ext_state_io.cpp — saveToActiveProject / loadFromProject / -// writeAssignmentRequest: the ext-state ↔ JSON serialization bridge, plus GUID -// minting and bank-folder relocation (see ext_state_io.h for the key contract). -// * prune_fs.cpp — pruneDryRun / pruneOrphanSet / pruneReclaim: the prune scan -// and THE SINGLE FILE-DELETION AUTHORITY over USER files in the bank folder in -// ReaSampler (deleteOrphanFile via SHFileOperationW). Nothing else in the system -// deletes bank-folder bytes; a shell's self-cleanup of a transient scratch file -// it just created (the drop path's .vstpreset temp, the realtime finalize temp) -// is excluded from this authority. +// Save: BankModel JSON -> SetProjExtState under namespace "reasampler" (ext +// state lives inside the .rpp, so the index travels with the project for +// free). Load: GetProjExtState -> deserialize -> resolve each entry's bank +// file against the CURRENT project dir, so a project opened from a new +// location still finds its bank. Save-As: relocate the physical bank folder +// so the wavs end up under the new .rpp; the index's relative paths stay valid. // -// Save: serialize the BankModel JSON -> SetProjExtState under namespace -// "reasampler" (ext state lives inside the .rpp, so the index travels with the -// project for free). -// Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory -// BankModel, then resolve each entry's bank file against the CURRENT project -// dir (project-relative resolution — a project opened from a new location still -// finds its bank). -// Save-As: when the project path changes, relocate the physical bank folder so -// the wavs end up under the new .rpp (the index's relative paths stay valid). -// -// The header is REAPER-free (no SDK types leak here): callers interact through a -// ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API -// calls live in the three TUs. It depends on bank_model (pure) for JSON round-trip -// and capture_paths (pure) for the path arithmetic it drives. +// REAPER-free header — all REAPER API calls live in the three TUs. #include #include @@ -46,268 +33,133 @@ namespace reasampler { -// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence +// Owns the session's BankBook (pool + named banks) and drives persistence // against the active REAPER project. One instance lives for the extension's -// lifetime (main.cpp). It tracks -// the project identity it last saw so the timer tick can detect a project load -// (a different project became active) and a Save-As (SAME project, path changed): +// lifetime. Tracks the project identity last seen so the timer tick can +// detect a project load (a different project became active, so load the +// index from ext state) vs. a Save-As (same project, path changed, so +// relocate the bank folder under the new .rpp). // -// * project load -> load the index from ext state, resolve bank paths -// * Save-As (new dir) -> relocate the bank folder under the new .rpp +// Identity is layered GUID-primary: the minted GUID (content-based, immune to +// REAPER recycling a closed project's ReaProject* address) is checked first; +// the live pointer disambiguates only the same-GUID case — a forked sibling +// (same GUID, different object -> Load) vs. a genuine Save-As (same GUID, +// same object, new path -> relocate). Two prior designs each broke one +// direction: GUID-only misread a Save-As fork as the parent project; +// pointer-primary misread a recycled ReaProject* address as no-op. GUID-first +// catches recycling; the pointer then separates fork from Save-As. // -// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of -// record, immune to REAPER recycling a closed project's ReaProject* address) is -// checked FIRST, and the live pointer disambiguates only the same-GUID case — a -// forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same -// GUID, same object, new path -> relocate). GUID-first catches pointer recycling -// (a reopened/new project reusing the previous address with a different GUID — the -// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As -// copies our GUID onto a distinct object — the W10 defect that clobbered a bank). -// -// The book itself is exposed for the capture/action layer to mutate; persist -// only reads it on save and replaces it on load. +// The book is exposed for the capture/action layer to mutate; persist only +// reads it on save and replaces it on load. class ReaSamplerSession { public: ReaSamplerSession() = default; - // The multi-bank book (Phase B): the pool + named banks, each wrapping a - // BankModel, plus the active-bank id. The action layer (B3) creates / renames / - // reorders / deletes banks and moves samples here; the panel (B4) reads it; - // persist serializes it under the `banks` key on save and replaces it on load. + // Pool + named banks + active-bank id; persist serializes under `banks`. BankBook& book() { return book_; } const BankBook& book() const { return book_; } - // The capture add-target: the ACTIVE bank's BankModel (defaults to the pool). - // The capture path adds a captured Sample through this seam, so a capture lands - // in whichever bank is active — the single behavioural change B2 wires in over - // M7/M8 (the capture backends are untouched; only the target index moved). The - // panel/insert readers that displayed the single index continue to read it here - // unchanged; today it resolves to the pool (default active), matching prior - // single-bank behaviour, until B3/B4 let the user switch the active bank. + // The capture add-target: the active bank's BankModel (defaults to the pool). model::BankModel& bank() { return book_.activeIndex(); } const model::BankModel& bank() const { return book_.activeIndex(); } - // The in-memory Design-View model. The view/action layer mutates it (tag, - // toggle, snapshot); persist serializes it on save and replaces it on project - // load — exactly as it treats the bank. D3 persists MODEL STATE only; applying - // visibility/processing (reapply-on-open) is D4's job, not this member's. + // Design-View model; persists MODEL STATE only (visibility on open is the view shell's job). ViewModeModel& view() { return view_; } const ViewModeModel& view() const { return view_; } - // The docked panel's tail setting (mode + manualMs), authoritative here — NOT in - // panel state — so it travels inside the .rpp: persist serializes it on save and - // replaces it on project load exactly as it treats the bank and view model. The - // panel reads/writes it through this seam (bank_panel holds the session), and the - // capture actions read it via bankPanelTailSetting. Default None / 2 s manual for - // an unsaved or pre-feature project (no stored key -> this default survives load). + // Docked panel's tail setting, authoritative here so it travels inside the .rpp. capture::TailSetting& tail() { return tail_; } const capture::TailSetting& tail() const { return tail_; } - // The owned-file manifest (Phase B B-cap): the set of project-relative files the - // capture path itself created. The capture add-path records each created file here - // (main.cpp, alongside the bank add), exactly as it adds the Sample to the active - // bank; persist serializes it under the `owned_files` key on save and replaces it on - // project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it; - // B-cap only writes and persists it (no prune logic here). + // Project-relative files the capture path itself created; prune consumes it. model::OwnedFileManifest& owned() { return owned_; } const model::OwnedFileManifest& owned() const { return owned_; } - // The ReaSampler version that last WROTE the active project, recovered from its - // ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no - // stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the - // exact stored string otherwise — all silent, never an error. Replaced on every load - // path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved - // or never-loaded session. Exposed so a future migration step (or diagnostics) can - // reason about the origin build without re-reading ext state. + // The version that last wrote the active project: PreVersioning (no + // stamp), Unknown (malformed), or Stamped. const version::WritingVersion& writingVersion() const { return writingVersion_; } - // The S9 bank-generation counter (the value stamped under `bank_generation`). Monotonic - // per project: recovered on load (so it continues from the stored value rather than - // resetting), bumped by bank-content mutations via bumpBankGeneration(), and written on - // every saveToActiveProject(). Exposed const for the writer sites to read/log. + // Monotonic per project; recovered on load, written on every saveToActiveProject(). std::int64_t bankGeneration() const { return bankGeneration_; } - // Bump the S9 bank-generation counter — call at every bank-CONTENT mutation that changes - // what a live instance would PLAY (capture add, re-capture-in-place, sample remove, - // move/copy affecting banks, ingest import). NOT the pure-organizational verbs (create / - // rename / activate / reorder a bank), which change no existing (bankId, sampleId) -> - // content mapping. The bumped value is persisted by the NEXT saveToActiveProject() call - // the same mutation already makes (the counter rides the persist blob, so there is no - // separate write). In-memory only here — cheap and REAPER-free; the persist is the write. - // Over-bumping is safe (a reload that finds unchanged content atomically re-installs the - // same instrument, no glitch); under-bumping misses a hands-free refresh, so the sites err - // toward bumping. Idempotent per logical op — call once per mutation, before the persist. + // Call at every bank-CONTENT mutation that changes what a live instance + // would play, NOT the organizational verbs (create/rename/reorder a + // bank). Rides the next persist. Over-bumping is safe; under-bumping + // misses a hands-free refresh, so call sites err toward bumping. void bumpBankGeneration() { ++bankGeneration_; } - // Serialize the current book (under the `banks` key), view model, and tail setting - // to the active project's ext state (namespace "reasampler"), and clear the retired - // legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys. - // Safe to call when there is no active/saved project (it no-ops). - // - // Returns true iff a persist actually happened (an active, SAVED project existed); - // false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a - // caller wrapping this in an undo block skip the block when nothing was written, so - // no dangling no-effect undo entry is opened on an unsaved project. + // Serializes book/view/tail to ext state, clears the retired legacy + // `bank_index` key. No-ops with no active/saved project. Returns true iff + // a persist happened, so a caller can skip an undo block when nothing was written. bool saveToActiveProject(); - // Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY, - // deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project- - // relative machinery the index/persist use — never a stale absolute path, so it is - // correct across a Save-As relocation), spells every enumerated entry with the index's - // own convention (bankRelativeForName — byte-identical to the capture path's spelling), - // and feeds the R1 pure core with (present, referenced, owned().paths()) where - // `referenced` = book().referencedPaths() ∪ every LIVE ReaSampler 9000 instance's - // held captures (pS-usage: usage_scan reads the per-instance rsusage_* ext-state - // records + the live FX enumeration; sample_usage decides liveness) — a capture any - // live instance holds can never be an orphan, so the prune can never delete it. - // FAIL-SAFE: a present-but-unreadable usage record sets the report's - // abortedUnreadableUsage flag with an EMPTY orphan set — the prune action halts. - // Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file - // list. The decision stays in the pure core — this method only enumerates, resolves, - // and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no - // save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file. - // - // Yields an empty report (count 0) when there is no active/saved project or no bank - // folder on disk yet — an unsaved or never-captured project has nothing to reclaim. + // Report-only prune dry-run: feeds the pure core with (present, + // referenced, owned), where `referenced` = book references union every + // live instance's held captures (usage_scan + sample_usage decide + // liveness). FAIL-SAFE: an unreadable usage record sets + // abortedUnreadableUsage with an EMPTY orphan set. Read-only throughout. reclaim::PruneReport pruneDryRun() const; - // The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh - // enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no - // 64-cap display clip) as project-relative index-spelled paths, in enumeration order. - // The R3 action calls this to obtain the exact set it will CONFIRM and then delete - // (pruneDryRun's truncated list is for the console readout; the delete set must be - // complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is - // no active/saved project or no bank folder yet. + // The full (untruncated) orphan set, same compute as pruneDryRun. The + // prune action confirms this set before deleting it. Read-only. std::vector pruneOrphanSet() const; - // Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path - // in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest. - // Given the orphan set the user was shown and confirmed (`confirmed`, typically the - // full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs - // the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan) - // so a file that vanished or became referenced between confirm and delete is skipped, - // never wrongly deleted — and a newly-appeared orphan the user did NOT see is never - // swept. Deletion routes to the OS trash where a portable move-to-trash is verified - // (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to - // std::filesystem unlink behind this confirm guardrail (see prune_fs.cpp for - // per-platform routing). Non-throwing: every filesystem call uses error_code forms; a - // per-file failure (locked, already gone) is recorded and skipped, never thrown across - // the C ABI. - // - // Does NOT modify the BankModel/book (orphans are unreferenced by definition) and does - // NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present) - // algebra naturally once it is off disk — no persist write, so no undo-point question - // and no risk to the referenced/owned safety). Writes NO ext-state at all. - // - // No-ops (empty result) when there is no active/saved project, no bank folder, or the - // delete plan is empty (everything went stale). The caller is responsible for having - // shown the confirm; this method does NOT prompt. + // Delete the confirmed orphan set — the sole file-deletion path, + // callable only after an explicit user confirm. Re-enumerates and runs + // the pure core fresh, deleting exactly `confirmed ∩ freshOrphans` so a + // file that vanished or became referenced since confirm is skipped, and + // an orphan the user did not see is never swept. Trash-preferred + // (Windows Recycle Bin; unlink elsewhere). Does not modify the book or + // OwnedFileManifest, writes no ext-state. No-ops when nothing to delete; + // does not prompt. reclaim::PruneDeletionResult pruneReclaim( const std::vector& confirmed) const; - // Write the S8 ingest ASSIGNMENT REQUEST to the active project's ext state (the - // `assign_request` key, namespace "reasampler"): the extension telling the active - // sampler instance "play THIS sample now." `wire` is the pure assignment_request - // encoding (assignment_request.h); this method only routes the already-encoded value - // to ext state + MarkProjectDirty — the (bankId, sampleId, generation) shaping and - // the encode live in the ingest shell (the pure module) so persist stays a thin bridge. - // - // A SIBLING one-shot write, NOT part of saveToActiveProject's book/view/tail blob: an - // assignment request is a transient "just assigned" signal the instrument reads and - // acts on, so it rides its own key and is written only at ingest time, never on every - // book save. Returns true iff written (an active, SAVED project existed); false on a - // no-active / unsaved project (nothing to write into — the assign is dropped, matching - // the book/manifest quiet-persist idiom the ingest add-path already tolerates). + // Write the ingest assignment request (`assign_request` key): "the active + // sampler instance should now play THIS sample." `wire` is pre-encoded + // (assignment_request.h); a sibling one-shot write, not part of + // saveToActiveProject's blob. Returns true iff written. bool writeAssignmentRequest(const std::string& wire); - // Poll the active project. Detects a project load (active project changed) - // and a Save-As (active project's .rpp path changed) and reacts accordingly. - // Intended to be driven by REAPER's "timer" register. Idempotent per tick. - // - // Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z - // keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the - // identity classifier below reads it as NoOp and would never re-read ext state. - // The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state - // restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the - // (now-restored) ext state of the current project — before the identity check, so - // the undo is reflected in-session without any content polling. + // Detects a project load or Save-As and reacts. Driven by REAPER's + // "timer" register; idempotent per tick. Also drains a pending undo/redo + // reload (requestReload): the identity classifier alone would read an + // undo/redo as NoOp since identity is unchanged, so the projectconfig + // hook's reload flag is honored FIRST, before the identity check. void poll(); - // Request a reload of book_ + view_ + tail_ from the CURRENT active project's ext - // state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY - // on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read) - // because the projectconfig callback fires BEFORE REAPER has restored the project's - // block — reading GetProjExtState synchronously there would return the - // PRE-undo value. Draining it on the next timer tick reads the restored value. This - // is REAPER-facing shell state; the request itself carries no REAPER types. + // Request a reload of book_/view_/tail_ on the next poll() tick. Raised + // by the projectconfig hook only on an undo/redo state restore. Deferred + // because the hook fires BEFORE REAPER restores the block — + // reading synchronously there would return the pre-undo value. void requestReload(); - // Load signal for the D4 reapply-on-open glue. poll() raises this whenever it - // (re)loads the view model from a project — prime, a project switch/open, or a - // forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears - // it, so the integration layer (main.cpp) can react by reapplying the saved - // active mode's visibility exactly once, then goes quiet on idle ticks. - // - // Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view - // shell), so there is no persist -> view dependency. main.cpp owns the glue — - // it drives both persist.poll() and view::applyMode, so the reapply wiring lives - // where those two already meet. D3 deliberately deferred exactly this to D4. + // Load signal for the reapply-on-open glue: poll() raises this whenever + // it (re)loads the view model; consumeLoadSignal() returns true once and + // clears it. Signal-based since persist stays model-only (never calls + // the view shell); main.cpp owns the glue. bool consumeLoadSignal(); private: BankBook book_; + ViewModeModel view_; // reset to default on a project with no stored view_state + capture::TailSetting tail_; // reset to default (None / 2s) with no stored tail key + model::OwnedFileManifest owned_; // reset to empty/stored on EVERY load path, never inherited + version::WritingVersion writingVersion_; // recovered per load; PreVersioning default + std::int64_t bankGeneration_ = 0; // recovered per load (absent -> 0); monotonic - // The Design-View model. Default-constructed = Arrange + Design seeded, active - // = Arrange; loadFromProject leaves this default when a project has no stored - // view_state (older project), so an absent key is graceful, not a crash. - ViewModeModel view_; - - // The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it - // to this default when a project has no stored tail_setting key (older / never- - // adjusted project), so an absent key is graceful. Peer to bank_/view_. - capture::TailSetting tail_; - - // The owned-file manifest. Default empty; loadFromProject resets it to empty (or the - // stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to - // a project with no stored manifest must not inherit the previous project's ownership - // record, and an undo that rolled back a capture must re-read the restored manifest so - // the in-memory set matches disk. Absent key -> empty is graceful (older project). - model::OwnedFileManifest owned_; - - // The writing-version stamp recovered on load (Phase V). Default PreVersioning; - // loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so - // switching to a pre-versioning project reports PreVersioning rather than inheriting - // the previous project's stamp. Read-only to consumers via writingVersion(). - version::WritingVersion writingVersion_; - - // The S9 bank-generation counter (peer to writingVersion_). Recovered on EVERY load path - // from the stored `bank_generation` stamp (parseBankGeneration; absent -> 0), so it - // continues monotonic from the persisted value across reopen and resets cleanly on a - // project switch (a different project's counter, not the previous one's). bumped by - // bumpBankGeneration() at bank-content mutations and stamped by saveToActiveProject(). - // Default 0 for an unsaved / never-loaded / pre-S9 session. - std::int64_t bankGeneration_ = 0; - - // The project identity last observed by poll(), used to detect load/Save-As. - // The GUID is the PRIMARY signal (a different stored GUID = a different project - // of record = Load, immune to pointer recycling). The pointer disambiguates the - // same-GUID case (different object = forked sibling -> Load; same object + new - // path -> Save-As) and drives forked-sibling re-divergence; the path tells a - // Save-As from an idle tick. - // Held as void* so the header stays REAPER-free; it is a compared-only opaque - // handle (never dereferenced), so a stale/recycled address is harmless. - void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only) + // Project identity last observed by poll(). GUID is primary; the pointer + // disambiguates the same-GUID case. Held as void* (compare-only, never + // dereferenced) so the header stays REAPER-free. + void* lastProject_ = nullptr; std::string lastGuid_; // "" until the first saved project is seen - std::string lastRppPath_; // .rpp path last seen for lastProject_ + std::string lastRppPath_; bool primed_ = false; // false until the first poll() observes state bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal - bool reloadRequested_ = false; // raised by requestReload (projectconfig undo/redo); drained by poll + bool reloadRequested_ = false; // raised by requestReload; drained by poll - // Load the book from the given project's ext state (the `banks` key, else the - // legacy `bank_index` key migrated into the pool) and resolve bank paths against - // projectDir at read time. Replaces the in-memory book. Also restores view_, tail_, - // and owned_ from their sibling keys on every load path. projectDir empty -> the - // book is reset to empty (unsaved project has no resolvable banks). + // Load the book from `proj`'s ext state (`banks`, else legacy + // `bank_index` migrated into the pool); also restores view_/tail_/owned_. void loadFromProject(void* proj, const std::string& projectDir); }; diff --git a/src/shell/persist/usage_scan.cpp b/src/shell/persist/usage_scan.cpp index 2c8b261..cbed7f7 100644 --- a/src/shell/persist/usage_scan.cpp +++ b/src/shell/persist/usage_scan.cpp @@ -1,20 +1,14 @@ -// usage_scan.cpp — see usage_scan.h. The REAPER reads behind the pS-usage prune -// protection; every decision is in the pure sample_usage module, this TU only reads. +// usage_scan.cpp — see usage_scan.h. The REAPER reads behind the instance-usage +// prune protection; every decision is in the pure sample_usage module, this TU +// only reads. // -// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h -// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers -// (CLAUDE.md §contract). Every REAPER symbol used here is verified against -// vendor/reaper-sdk/sdk/reaper_plugin_functions.h: -// * EnumProjExtState(proj, extname, idx, keyOut, sz, valOut, sz) -> bool (~1272) -// * GetProjExtState(proj, extname, key, valOut, sz) -> int (~2591) -// * CountTracks / GetTrack / GetMasterTrack (track scan) -// * TrackFX_GetCount(MediaTrack*) / TrackFX_GetRecCount(MediaTrack*) (~7283/7570) -// * TrackFX_GetNamedConfigParm(MediaTrack*, int, parm, buf, sz) -> bool (~7377) -// * CountMediaItems / GetMediaItem (~423/1964) -// * CountTakes(MediaItem*) / GetMediaItemTake(MediaItem*, int) (~471/2029) -// * GetMediaItemTrack(MediaItem*) (~2133) -// * TakeFX_GetCount / TakeFX_GetNamedConfigParm (~6710/6774) -// * guidToString (via track_guid::guidString) +// Compiled into the reaper_reasampler module. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API +// pointers (CLAUDE.md §contract). REAPER symbols used here (EnumProjExtState, +// GetProjExtState, CountTracks/GetTrack/GetMasterTrack, TrackFX_GetCount/ +// GetRecCount/GetNamedConfigParm, CountMediaItems/GetMediaItem, CountTakes/ +// GetMediaItemTake, GetMediaItemTrack, TakeFX_GetCount/GetNamedConfigParm) are +// verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h. #include "shell/persist/usage_scan.h" @@ -26,11 +20,11 @@ #include #include "core/version/app_version.h" // vstPluginName / vstOutputName (channel name needles) -#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy) +#include "core/wire/ext_state_read.h" // readProjExtStateGrowing — the shared grow-loop policy #include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix #include "core/wire/instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex #include "core/wire/sample_usage.h" // identityMatches, foldUsageRecords (the pure decisions) -#include "shell/capture/track_guid.h" // guidString — the ONE canonical GUID key formatter +#include "shell/capture/track_guid.h" // guidString — the canonical GUID key formatter #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjExtState @@ -52,10 +46,9 @@ namespace reasampler { -// Real-namespace-home using-directive (Q-W6: the namespaces.h shim is retired): -// this TU speaks the sample_usage wire vocabulary wholesale (UsageRecord / -// decodeUsageRecord / foldUsageRecords / identityMatches / toUpperAscii) plus the -// channel-identity accessors + the preset class-id hex. +// This TU speaks the sample_usage wire vocabulary wholesale (UsageRecord / +// decodeUsageRecord / foldUsageRecords / identityMatches / toUpperAscii) plus +// the channel-identity accessors + the preset class-id hex. using namespace reasampler::wire; using version::vstOutputName; using version::vstPluginName; @@ -76,16 +69,15 @@ struct FxIdentityNeedles { using FxParmGetter = std::function; -// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a -// ReaSampler 9000. BOTH fx_ident and original_name are checked on BOTH chain kinds (a -// renamed instance may keep its original_name; fx_ident carries the module path — the -// primary identification net is the module filename base via fx_ident, which holds even -// after a user renames the FX instance). Containers are walked via -// the documented container_count / container_item.X addressing (v7.06+); on a chain -// kind or REAPER version without containers the parm read returns empty and recursion -// is a no-op. `depth` bounds pathological nesting. fx_ident is queried per FX — chain -// enumeration is chunk-level, so OFFLINE instances match too (load-bearing: a -// Design-View-parked instance must keep protecting its holds). +// True if any FX in the (possibly container-nested) sub-chain rooted at +// `fxId` is a ReaSampler 9000. Both fx_ident and original_name are checked (a +// renamed instance may keep its original_name; fx_ident carries the module +// path and survives a rename). Containers are walked via the documented +// container_count / container_item.X addressing (v7.06+); on a chain kind or +// REAPER version without containers the parm read returns empty and +// recursion is a no-op. `depth` bounds pathological nesting. fx_ident is +// queried per FX — chain enumeration is chunk-level, so OFFLINE instances +// match too (a Design-View-parked instance must keep protecting its holds). bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId, const FxIdentityNeedles& id, int depth) { if (identityMatches(parm(fxId, "fx_ident"), id.uidHexUpper, id.nameUpper, @@ -96,12 +88,9 @@ bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId, const std::string countStr = parm(fxId, "container_count"); if (countStr.empty()) return false; // not a container; no children to miss if (depth <= 0) { - // This node IS a container but we have exhausted our descent budget. We cannot - // prove that none of its children is a ReaSampler 9000 instance — treat the - // incomplete walk as a positive identification (the protect direction). This is - // defense-in-depth: kMaxContainerDepth = 32 should prevent reaching this branch - // in any real project, but if it IS reached the fail-safe fires rather than - // silently missing a live nested instance. + // Descent budget exhausted on a node that IS a container: we cannot + // prove none of its children is an instance, so treat the incomplete + // walk as a positive identification (protect direction). return true; } const int n = std::atoi(countStr.c_str()); @@ -116,9 +105,9 @@ bool fxSubtreeHasInstance(const FxParmGetter& parm, int fxId, return false; } -// Raised from 8 to 32 (defense in depth against truncation). Real-world FX containers -// are typically 2–4 levels deep; 32 is unreachable in practice while remaining finite. -// Even at 32, the truncation→protect-all guard below is the primary protection. +// Real-world FX containers are typically 2-4 levels deep; 32 is unreachable +// in practice while remaining finite. The truncation->protect-all guard above +// is the primary protection even at this depth. constexpr int kMaxContainerDepth = 32; std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) { @@ -153,11 +142,9 @@ bool trackHasInstance(MediaTrack* tr, const FxIdentityNeedles& id) { return false; } -// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just active — a -// non-active take's instance still exists in the project and reactivates with the -// take). The SAME identity walk as the track path: fx_ident + original_name + container -// recursion (an unrecognized exotic still lands in the pure protect-all net — records -// with zero identified instances protect everything rather than nothing). +// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just +// active — a non-active take's instance still exists and reactivates with +// the take). Same identity walk as the track path. bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) { const int takes = CountTakes(item); for (int t = 0; t < takes; ++t) { @@ -174,15 +161,11 @@ bool itemHasInstance(MediaItem* item, const FxIdentityNeedles& id) { return false; } -// Growing GetProjExtState read: the usage record scales with the hold count, so a -// fixed buffer risks a truncated decode. The retry policy is the SHARED pure -// wire::readProjExtStateGrowing (T2-04 — one loop for persist, this -// prune-safety-adjacent read, and the VST bridge; the rules cannot drift). -// Returns nullopt when the key cannot be read WHOLE — absent-after-enumeration -// (rv <= 0) or pathologically large (> 16 MB give-up). The caller only queries keys -// the enumeration just listed, so a nullopt here is a PRESENT-BUT-UNREADABLE record: -// it folds to abortPrune (fail-safe — silently reduced protection is the delete -// direction). +// The usage record scales with the hold count, so a fixed buffer risks a +// truncated decode; uses the shared grow-loop policy. Returns nullopt when +// the key cannot be read whole (absent, or > 16 MB give-up). The caller only +// queries keys the enumeration just listed, so nullopt here is a +// present-but-unreadable record: it folds to abortPrune. std::optional readExtStateValue(ReaProject* proj, const char* key) { const GrowingExtStateRead read = readProjExtStateGrowing( [&](char* buf, int cap) { @@ -198,10 +181,8 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) { ReaProject* proj = static_cast(projOpaque); UsageScanResult result; - // 1. Enumerate the rsusage_* keys and read+decode each record. Key names first - // (values via the growing reader — EnumProjExtState's fixed val buffer could - // truncate a large record). A nullopt element = present-but-unreadable/ - // undecodable -> the pure fold ABORTS the prune. + // Enumerate rsusage_* keys, then read+decode via the growing reader + // (EnumProjExtState's fixed val buffer could truncate a large record). std::vector usageKeys; { const std::string prefix = kProjExtUsageKeyPrefix; // hoisted: one alloc, not N @@ -232,8 +213,8 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) { decoded.push_back(rec); // undecodable nullopt -> abort } - // 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen needle set drives - // every match; a track needs only ONE instance to keep all its records live. + // Enumerate live ReaSampler 9000 hosts; a track needs only one instance to + // keep all its records live. FxIdentityNeedles id; id.uidHexUpper = toUpperAscii(vstClassIdHex()); id.outputNameUpper = toUpperAscii(vstOutputName()); @@ -270,14 +251,12 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) { } } - // 3. The pure fold decides: abort on any unreadable record; protect-all when zero - // instances were identified; otherwise the per-record liveness rule. + // The pure fold decides: abort on any unreadable record; protect-all when + // zero instances were identified; otherwise the per-record liveness rule. const UsageFoldResult fold = foldUsageRecords(decoded, liveTrackGuids, anyLive); result.abortPrune = fold.abortPrune; result.heldPaths = fold.heldPaths; - // offendingKeys already populated above (unreadable + undecodable entries); - // clear it on success so callers see it only when abortPrune is set. - if (!result.abortPrune) result.offendingKeys.clear(); + if (!result.abortPrune) result.offendingKeys.clear(); // only meaningful on abort return result; } diff --git a/src/shell/persist/usage_scan.h b/src/shell/persist/usage_scan.h index e9fd5d8..8b3b14d 100644 --- a/src/shell/persist/usage_scan.h +++ b/src/shell/persist/usage_scan.h @@ -1,51 +1,37 @@ #pragma once -// usage_scan — the EXTENSION-side shell of the pS-usage seam (see sample_usage.h for -// the pure core, the fail-safe folds, and the full design note). At prune-scan time it -// answers ONE question: which project-relative bank paths are held by a LIVE ReaSampler -// 9000 instance — or must the prune ABORT because a usage record could not be read? +// usage_scan — the extension-side shell of the instance-usage seam (see +// sample_usage.h for the pure core and fail-safe folds). At prune-scan time it +// answers one question: which project-relative bank paths are held by a live +// ReaSampler 9000 instance — or must the prune abort because a usage record +// could not be read? // -// Three reads, no writes (the prune scan's READ-ONLY contract holds): -// 1. Enumerate every "rsusage_" key in the "reasampler" ext-state namespace -// (EnumProjExtState) and decode each record (sample_usage wire). A key that is -// present but cannot be read or decoded folds to abortPrune (fail-safe: an -// unreadable record may protect anything, so the prune halts and deletes nothing). -// 2. Enumerate every ReaSampler 9000 FX instance in the project — all tracks -// (master included), normal + record/input chains, FX containers recursively, and -// take FX (same container recursion) — matching each FX's fx_ident AND -// original_name via the pure sample_usage::identityMatches (class-UID hex, module -// filename base, display name; see the matcher note there). -// 3. Fold with the pure liveness rule (sample_usage::foldUsageRecords / -// usageHeldPaths): a record counts iff its publishing track still hosts >= 1 -// instance; a record with no track context counts while any instance exists; and -// when records exist but ZERO instances were identified anywhere, EVERY record's -// paths are protected (the identity-failure net — a matcher failure must never -// degrade toward delete). +// Three reads, no writes: (1) enumerate every "rsusage_" key and decode +// each record — unreadable/undecodable folds to abortPrune; (2) enumerate +// every ReaSampler 9000 FX instance (all tracks incl. master, normal + +// record/input chains, containers recursively, take FX) via +// sample_usage::identityMatches; (3) fold with the pure liveness rule — zero +// instances identified anywhere protects every record's paths. // -// The result feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans, so -// `referenced` = bank references ∪ live-instance holds — a held capture can never be -// an orphan, and BANK_PRUNE_FOLDER (the only deletion authority) can never delete it. -// abortPrune propagates through PruneScan/PruneReport to the action, which halts. +// Feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans — a +// held capture can never be an orphan. abortPrune propagates to the action, +// which halts. // // REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT -// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers — CLAUDE.md §contract). The -// header stays REAPER-free (`proj` is the opaque ReaProject* the persist seam already -// passes around as void*). +// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers). The header stays +// REAPER-free (`proj` is the opaque ReaProject* passed as void*). #include #include namespace reasampler { -// The scan outcome. When abortPrune is true a present rsusage_* record could not be -// read or decoded — the caller MUST halt the prune (delete nothing). offendingKeys -// names the exact "rsusage_" keys that triggered the abort so the action can -// print them for operator recovery (clear via ReaScript: -// reaper.SetProjExtState(0, "reasampler", "", "") -// for each offending key). heldPaths on abort is the protect-all set (every readable -// record's paths) — meaningful only as a belt-and-braces fallback; the abort flag is -// the authoritative signal. Otherwise heldPaths is every project-relative path held by -// a live ReaSampler 9000 instance, de-duped, in record order — empty in the common -// no-records case (the FX enumeration is skipped entirely). +// When abortPrune is true, a present rsusage_* record could not be read or +// decoded — the caller MUST halt the prune. offendingKeys names the exact +// keys that triggered the abort, so the action can print them for recovery +// (clear via ReaScript: reaper.SetProjExtState(0, "reasampler", "", "")). +// heldPaths on abort is the protect-all set — a belt-and-braces fallback; the +// abort flag is authoritative. Otherwise heldPaths is every project-relative +// path held by a live instance, de-duped, in record order. struct UsageScanResult { bool abortPrune = false; std::vector offendingKeys; // non-empty iff abortPrune