Cut core/wire and shell/persist comment bloat ~46% (comments only, zero code change)

This commit is contained in:
2026-07-29 20:49:28 -04:00
parent 1f24c4b095
commit 8dac5b4a54
19 changed files with 638 additions and 1286 deletions
-3
View File
@@ -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;
+23 -59
View File
@@ -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 <cstdint>
#include <optional>
@@ -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" <len>':'<bankId> <len>':'<sampleId> <len>':'<generation-decimal>
// where each <len> 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<AssignmentRequest> decodeAssignmentRequest(const std::string& wire);
} // namespace reasampler::wire
+10 -20
View File
@@ -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<u32>/putLE<u64> 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<std::uint8_t>& bytes;
std::size_t pos = 0;
+14 -19
View File
@@ -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 <cstddef>
#include <string>
@@ -54,7 +49,7 @@ GrowingExtStateRead readProjExtStateGrowing(ReadFn&& read) {
result.status = GrowingExtStateRead::Status::Absent;
return result;
}
buf[static_cast<std::size_t>(cap) - 1] = '\0'; // defensive: guard against a read() that fills the buffer without honoring NUL-termination within cap
buf[static_cast<std::size_t>(cap) - 1] = '\0'; // guard a read() that ignores NUL-termination within cap
std::string s(buf.data());
if (static_cast<int>(s.size()) + 1 < cap) {
result.status = GrowingExtStateRead::Status::Complete;
+8 -25
View File
@@ -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 <cstdio>
#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<std::uint8_t>& out, const char id[4]) {
out.insert(out.end(), id, id + 4);
@@ -28,9 +27,6 @@ void appendFourCC(std::vector<std::uint8_t>& 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<unsigned>(REASAMPLER_ACTIVE_UID_1),
@@ -69,12 +65,8 @@ std::vector<std::uint8_t> buildVstPresetBytes(
}
std::vector<std::uint8_t> 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<std::uint8_t> 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");
+47 -90
View File
@@ -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, "<absolute path>.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 <VST block), NOT the raw
// IComponent::setState stream. Writing raw component-state bytes there "succeeds" (the parm
// write returns true) but REAPER's VST3 wrapper cannot apply the unframed blob, so the
// instance silently stayed at defaults — the observed blank-on-drop. The .vstpreset path
// replaces that undocumented framing with a Steinberg-DOCUMENTED container this module can
// construct byte-exactly and prove in a unit test (public.sdk/source/vst/vstpresetfile.cpp
// is the reference reader/writer; layout verified against it).
//
// The component-state bytes inside the preset are still produced by the instrument's OWN
// serializer, sample_map::serializeComponentState (the single source of truth for the byte
// layout — the same function the processor's getState calls), so the cross-artifact
// contract cannot drift: if the instrument's format changes, this module changes with it
// because it CALLS it.
// The component-state bytes are produced by the instrument's own serializer
// (sample_map::serializeComponentState, the same function getState calls),
// so this module cannot drift from the instrument's format.
#include <cstdint>
#include <string>
@@ -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<std::uint8_t> buildVstPresetBytes(const std::string& classIdHex32,
const std::vector<std::uint8_t>& 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<std::uint8_t> 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<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId);
} // namespace reasampler::wire
+12 -19
View File
@@ -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
+21 -57
View File
@@ -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<UsageRecord> decodeUsageRecord(const std::string& wire) {
UsagePublishPlan planUsagePublish(const std::optional<std::string>& 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<UsageRecord> 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<std::string>& 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<std::string>& 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<UsageRecord>& 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<std::string> seen;
for (const std::optional<UsageRecord>& 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;
}
+99 -178
View File
@@ -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_<instanceGuid>", 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_<instanceGuid>"); 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 <optional>
#include <string>
@@ -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" <len>':'<trackGuid> <len>':'<ownerNonce> <len>':'<unioned "0"|"1">
// <len>':'<holdCount-decimal> then per hold: <len>':'<sampleId> <len>':'<relativePath>
// 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" <len>':'<trackGuid> <len>':'<ownerNonce> <len>':'<unioned "0"|"1">
// <len>':'<holdCount> then per hold: <len>':'<sampleId> <len>':'<relativePath>
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<UsageRecord> 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<std::string>& 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<std::string> usageHeldPaths(
const std::vector<UsageRecord>& records,
const std::unordered_set<std::string>& 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<std::string>& 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);
+2 -4
View File
@@ -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"
+19 -34
View File
@@ -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 `<decimal-len>':'<bytes>` 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, `<decimal-len>':'
// <bytes>`, 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: <decimal-len> ':' <bytes>
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: