Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green

This commit is contained in:
2026-07-28 20:48:56 -04:00
parent 67a41728f3
commit 847936f813
222 changed files with 2247 additions and 2079 deletions
+43
View File
@@ -0,0 +1,43 @@
// assignment_request.cpp — see assignment_request.h. Pure: standard library only.
#include "core/wire/assignment_request.h"
#include "core/wire/wire.h"
namespace reasampler::wire {
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;
} // namespace
std::string encodeAssignmentRequest(const AssignmentRequest& req) {
std::string out = kMagic;
putField(out, req.bankId);
putField(out, req.sampleId);
putField(out, std::to_string(req.generation));
return out;
}
std::optional<AssignmentRequest> decodeAssignmentRequest(const std::string& wire) {
Cursor cur(wire);
if (!cur.literal(kMagic)) return std::nullopt;
AssignmentRequest req;
if (!cur.field(req.bankId)) return std::nullopt;
if (!cur.field(req.sampleId)) return std::nullopt;
if (!cur.fieldInt64(req.generation)) return std::nullopt;
// Reject trailing garbage: a well-formed value ends exactly at the last field.
if (!cur.ok() || !cur.atEnd()) return std::nullopt;
return req;
}
} // namespace reasampler::wire
+89
View File
@@ -0,0 +1,89 @@
#pragma once
// assignment_request — the pure core of the S8 ingest assignment-request seam.
//
// 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.
//
// -- 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.
#include <cstdint>
#include <optional>
#include <string>
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.
struct AssignmentRequest {
std::string bankId;
std::string sampleId;
std::int64_t generation = 0;
bool operator==(const AssignmentRequest& o) const {
return bankId == o.bankId && sampleId == o.sampleId &&
generation == o.generation;
}
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):
// "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.
//
// 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.
std::optional<AssignmentRequest> decodeAssignmentRequest(const std::string& wire);
} // namespace reasampler::wire
+109
View File
@@ -0,0 +1,109 @@
// 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 (vst/reasampler_uid.h).
#include "core/wire/instrument_drop.h"
#include <cstdio>
#include "shell/instrument/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID
#include "core/instrument/map/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer)
namespace reasampler::wire {
namespace {
// Little-endian appenders — the .vstpreset container stores its integers little-endian on
// disk (public.sdk vstpresetfile.cpp swaps only on big-endian hosts).
void appendU32LE(std::vector<std::uint8_t>& out, std::uint32_t v) {
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
}
void appendU64LE(std::vector<std::uint8_t>& out, std::uint64_t v) {
for (int i = 0; i < 8; ++i)
out.push_back(static_cast<std::uint8_t>((v >> (8 * i)) & 0xFF));
}
void appendFourCC(std::vector<std::uint8_t>& out, const char id[4]) {
out.insert(out.end(), id, 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),
static_cast<unsigned>(REASAMPLER_ACTIVE_UID_2),
static_cast<unsigned>(REASAMPLER_ACTIVE_UID_3),
static_cast<unsigned>(REASAMPLER_ACTIVE_UID_4));
return std::string(buf, 32);
}
std::vector<std::uint8_t> buildVstPresetBytes(
const std::string& classIdHex32, const std::vector<std::uint8_t>& componentState) {
std::vector<std::uint8_t> out;
if (classIdHex32.size() != 32) return out; // contract violation -> empty, never throws
// Header (48 bytes): 'VST3' + int32 version + 32-char class ID + int64 list offset.
constexpr std::uint64_t kHeaderSize = 4 + 4 + 32 + 8;
const std::uint64_t compSize = componentState.size();
const std::uint64_t listOffset = kHeaderSize + compSize;
out.reserve(static_cast<std::size_t>(listOffset) + 4 + 4 + (4 + 8 + 8));
appendFourCC(out, "VST3");
appendU32LE(out, 1); // kFormatVersion
out.insert(out.end(), classIdHex32.begin(), classIdHex32.end());
appendU64LE(out, listOffset);
// Data area: the one 'Comp' chunk's bytes, at offset kHeaderSize.
out.insert(out.end(), componentState.begin(), componentState.end());
// Chunk list: 'List' + entry count + one entry {'Comp', offset, size}.
appendFourCC(out, "List");
appendU32LE(out, 1);
appendFourCC(out, "Comp");
appendU64LE(out, kHeaderSize);
appendU64LE(out, compSize);
return out;
}
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.
ComponentState cs;
cs.selectionId = sampleId;
return serializeComponentState(cs);
}
std::vector<std::uint8_t> buildInstrumentDropPreset(const std::string& sampleId) {
return buildVstPresetBytes(vstClassIdHex(), instrumentDropStateBytes(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.
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");
}
} // namespace reasampler::wire
+114
View File
@@ -0,0 +1,114 @@
#pragma once
// instrument_drop — the PURE payload-construction core of S17 drop-and-load.
//
// 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 vst/reasampler_uid.h). Unit-tested outside the DAW — the same
// "small pure builder + round-trip proof" pattern as assignment_request / provenance.
//
// -- What it is (the S17 seam, extension side) --------------------------------
//
// 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.
#include <cstdint>
#include <string>
#include <vector>
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 vst/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.
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
// [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).
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.
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).
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).
std::vector<std::uint8_t> instrumentDropStateBytes(const std::string& sampleId);
} // namespace reasampler::wire
+233
View File
@@ -0,0 +1,233 @@
// sample_usage.cpp — see sample_usage.h. Pure: standard library only.
#include "core/wire/sample_usage.h"
#include <cctype>
#include "core/wire/wire.h"
namespace reasampler::wire {
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;
} // namespace
std::string encodeUsageRecord(const UsageRecord& rec) {
std::string out = kMagic;
putField(out, rec.trackGuid);
putField(out, rec.ownerNonce);
putField(out, rec.unioned ? "1" : "0");
putField(out, std::to_string(rec.holds.size()));
for (const UsageHold& h : rec.holds) {
putField(out, h.sampleId);
putField(out, h.relativePath);
}
return out;
}
std::optional<UsageRecord> decodeUsageRecord(const std::string& wire) {
Cursor c(wire);
if (!c.literal(kMagic)) return std::nullopt;
UsageRecord rec;
if (!c.field(rec.trackGuid)) return std::nullopt;
if (!c.field(rec.ownerNonce)) return std::nullopt;
std::string unionedField;
if (!c.field(unionedField)) return std::nullopt;
if (unionedField == "1") rec.unioned = true;
else if (unionedField == "0") rec.unioned = false;
else return std::nullopt; // anything else is corruption -> reject whole
std::size_t count = 0;
if (!c.fieldSizeT(count)) return std::nullopt;
// Each hold needs at least 4 wire bytes ("0:0:"), so a count past wire.size()/4 is
// provably bogus — reject before looping rather than iterating a crafted huge count.
if (count > wire.size() / 4u + 1u) return std::nullopt;
rec.holds.reserve(count);
for (std::size_t i = 0; i < count; ++i) {
UsageHold h;
if (!c.field(h.sampleId)) return std::nullopt;
if (!c.field(h.relativePath)) return std::nullopt;
rec.holds.push_back(std::move(h));
}
if (!c.ok() || !c.atEnd()) return std::nullopt; // trailing garbage -> reject whole
return rec;
}
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;
}
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.
plan.remint = true;
return plan;
}
const bool nonceMatch =
!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.
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.
UsageRecord merged;
merged.trackGuid = mine.trackGuid;
merged.ownerNonce = mine.ownerNonce;
merged.unioned = true;
merged.holds = theirs->holds;
for (const UsageHold& h : mine.holds) {
bool dup = false;
for (const UsageHold& e : merged.holds) {
if (e == h) { dup = true; break; }
}
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.)
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.
plan.remint = true;
return plan;
}
std::vector<std::string> usageHeldPaths(
const std::vector<UsageRecord>& records,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive) {
std::vector<std::string> out;
std::unordered_set<std::string> seen;
// FAIL-SAFE NET: records exist but not one instance was identified live anywhere —
// indistinguishable from an identity-matcher failure, so protect EVERY record's
// paths rather than none (zero-identified must never degrade toward delete).
const bool protectAll = !records.empty() && !anyInstanceLive;
for (const UsageRecord& rec : records) {
const bool live = protectAll ||
(rec.trackGuid.empty()
? anyInstanceLive
: (liveTrackGuids.count(rec.trackGuid) != 0));
if (!live) continue;
for (const UsageHold& h : rec.holds) {
if (h.relativePath.empty()) continue;
if (seen.insert(h.relativePath).second) out.push_back(h.relativePath);
}
}
return out;
}
UsageFoldResult foldUsageRecords(
const std::vector<std::optional<UsageRecord>>& decoded,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive) {
UsageFoldResult result;
std::vector<UsageRecord> records;
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.
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;
for (const UsageHold& h : r->holds) {
if (h.relativePath.empty()) continue;
if (seen.insert(h.relativePath).second)
result.heldPaths.push_back(h.relativePath);
}
}
return result;
}
records.push_back(*rec);
}
result.heldPaths = usageHeldPaths(records, liveTrackGuids, anyInstanceLive);
return result;
}
std::string toUpperAscii(const std::string& s) {
std::string out = s;
for (char& c : out)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return out;
}
bool identityMatches(const std::string& identity, const std::string& uidHexUpper,
const std::string& nameUpper,
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).
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;
}
} // namespace reasampler::wire
+253
View File
@@ -0,0 +1,253 @@
#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.
//
// 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.
//
// -- The data-ownership boundary (load-bearing) -------------------------------
//
// 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) -----------------
//
// 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.
//
// -- 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).
#include <optional>
#include <string>
#include <unordered_set>
#include <vector>
namespace reasampler::wire {
// One held capture: the bank sample id (attribution/debugging) + the project-relative
// WAV path (the prune-protection payload — compared by EXACT string against the prune
// core's `present` spelling, which both sides source from the same bank-blob spelling).
struct UsageHold {
std::string sampleId;
std::string relativePath;
bool operator==(const UsageHold& o) const {
return sampleId == o.sampleId && relativePath == o.relativePath;
}
};
// 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.
struct UsageRecord {
std::string trackGuid;
std::string ownerNonce;
bool unioned = false;
std::vector<UsageHold> holds;
bool operator==(const UsageRecord& o) const {
return trackGuid == o.trackGuid && ownerNonce == o.ownerNonce &&
unioned == o.unioned && holds == o.holds;
}
};
// 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>
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::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).
// * wire — the encoded value to write (mine, or the same-track union).
struct UsagePublishPlan {
bool remint = false;
bool skipWrite = false;
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).
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).
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
// usageHeldPaths (including its protect-all net).
struct UsageFoldResult {
bool abortPrune = false;
std::vector<std::string> heldPaths;
};
UsageFoldResult foldUsageRecords(
const std::vector<std::optional<UsageRecord>>& decoded,
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).
bool identityMatches(const std::string& identity, const std::string& uidHexUpper,
const std::string& nameUpper, const std::string& outputNameUpper);
// ASCII-only uppercase (shared by the matcher and the shell's needle preparation).
std::string toUpperAscii(const std::string& s);
} // namespace reasampler::wire