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