#pragma once // sample_usage — the pure core of the pS-usage seam: ReaSampler 9000 instances count // as USAGE for the prune. Each live instance PUBLISHES the captures it holds (its v10 // SampleRefs — sample ids + project-relative paths) to a per-instance project ext-state // key ("usage_", see ext_keys.h); the EXTENSION reads every usage record // at prune-scan time, keeps only the records backed by a live ReaSampler 9000 FX // instance, and folds the surviving paths into the prune's `referenced` set — so a file // any live instance holds can never be an orphan and BANK_PRUNE_FOLDER can never // delete it. // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO VST3, NO SWELL, // NO vendor/ includes. Standard library only. The mirror of assignment_request (the // other VST<->extension ext-state wire): the wire format AND the two safety-critical // decisions (what to write on publish, which records count at prune time) live here so // they are provable without a DAW. The shells only move strings. // // -- The data-ownership boundary (load-bearing) ------------------------------- // // The INSTRUMENT writes usage keys; the EXTENSION reads them. This is the ONE sanctioned // instrument->ext-state write (Daniel's ruling: "if that means the VST writes to the // bridge when it grabs a capture, so be it") and it does NOT weaken the read-only-BANK // invariant: the instrument publishes its OWN usage under its OWN per-instance key, // and never touches banks/view/tail/assign or any other extension-owned key. The bridge // enforces this structurally — its write entry point accepts only "usage_"-prefixed keys. // // -- Liveness (no stale-key false-protect, no false-delete) -------------------- // // A usage record must protect exactly the captures of instances that still EXIST. Two // rejected designs shape the rules below: // * NO teardown clearing. The obvious "clear my key in terminate()" is WRONG here: // REAPER destroys the plugin instance when an FX is set OFFLINE — including the // extension's own Design View CPU-park (per-FX offline on inactive-mode tracks). A // terminate-time clear would strip the record of an instance that still exists in // the project, opening a prune-deletes-a-used-file window. Records are therefore // never cleared by the instrument; staleness is resolved by the EXTENSION at read // time against the live FX enumeration. // * NO challenge/response. Instances only poll ext-state on the EDITOR's UI timer // (pollBankSync); a closed-editor instance could never answer a prune-time // challenge, and its holds would be false-deleted. Publishing is therefore EAGER // (on load + on every play-set change via reloadInstrument), and liveness is // decided extension-side. // // The liveness rule (usageHeldPaths): a record counts iff the track it was published // from still exists AND that track still hosts at least one ReaSampler 9000 FX // instance (offline FX included — chain enumeration is chunk-level, so a parked // instance still protects its holds). A record whose track GUID could not be resolved // at publish time (empty) counts while ANY ReaSampler 9000 instance exists in the // project — the fail-safe fallback. The residual: a deleted instance whose track still // hosts a sibling 9000 keeps its record alive (false-PROTECT only — prune skips a file // it could have reclaimed; never the delete direction). 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. The publish // plan resolves every collision in the fail-safe direction: // * existing value == what THIS instance wrote this lifetime -> clean replace (the // normal single-owner path; holds the instance released genuinely drop). // * existing value is foreign but from the SAME track -> UNION of holds (a same-track // copy; neither sibling's holds may be dropped — over-protects until the next clean // replace, never under-protects). // * existing value is foreign from a DIFFERENT track -> 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 #include #include #include namespace reasampler { // One held capture: the bank sample id (attribution/debugging) + the project-relative // WAV path (the prune-protection payload — compared by EXACT string against the prune // core's `present` spelling, which both sides source from the same bank-blob spelling). struct UsageHold { std::string sampleId; std::string relativePath; bool operator==(const UsageHold& o) const { return sampleId == o.sampleId && relativePath == o.relativePath; } }; // One instance's published usage: the REAPER track GUID it was hosted on at publish // time ("{...}" canonical form; empty when the host context could not resolve one) plus // every capture it holds. The record is self-contained — the extension needs nothing // from the instance beyond this value and the live FX enumeration. struct UsageRecord { std::string trackGuid; std::vector holds; bool operator==(const UsageRecord& o) const { return trackGuid == o.trackGuid && holds == o.holds; } }; // Encode a usage record to the wire string. Length-prefixed fields behind a magic tag // ("rsusage1"), the same idiom as assignment_request / provenance, so arbitrary bytes // in a GUID or path round-trip whole. Deterministic. // // FORMAT: "rsusage1" ':' ':' // then per hold: ':' ':' std::string encodeUsageRecord(const UsageRecord& rec); // Parse a wire string produced by encodeUsageRecord. std::nullopt on malformed / // truncated / trailing-garbage input (never UB, never a partial value). The extension // treats an undecodable record as absent — it can protect nothing it cannot read. std::optional decodeUsageRecord(const std::string& wire); // The publish decision computed BEFORE a write (see the identity note above). // * remint — true when the existing key value belongs to a live foreign instance // on another track: the caller must mint a fresh instance GUID and // write under the NEW key, leaving the existing record untouched. // * skipWrite — true when the write would be byte-identical to what this instance // already wrote this lifetime (idle reload tick) — skip the ext-state // churn entirely. // * wire — the encoded value to write (mine, or the same-track union). struct UsagePublishPlan { bool remint = false; bool skipWrite = false; std::string wire; }; // Decide what to write for `mine` given the key's current value and what this instance // last wrote THIS LIFETIME (empty string = nothing yet this lifetime — a fresh load; // the existing value is then this instance's own persisted record from the last // session, OR a copy-source's record: same-track -> union, other-track -> remint). // * existing absent or undecodable -> write mine. // * existing == lastPublishedThisLifetime -> write mine (clean replace). // * existing.trackGuid == mine.trackGuid -> write union(existing.holds, mine.holds) // (existing-first order, de-duped). // * else -> remint = true, write mine (new key). UsagePublishPlan planUsagePublish(const std::optional& existing, const std::string& lastPublishedThisLifetime, const UsageRecord& mine); // The prune-side fold: every project-relative path held by a LIVE instance, de-duped, // in (record, hold) input order. A record counts iff // * its trackGuid is non-empty and present in `liveTrackGuids` (a track that still // exists AND still hosts >= 1 ReaSampler 9000 FX — the caller's enumeration), OR // * its trackGuid is empty and `anyInstanceLive` is true (the fail-safe fallback for // a record published without a resolvable track context). // Holds with an empty relativePath are skipped (nothing to protect). std::vector usageHeldPaths( const std::vector& records, const std::unordered_set& liveTrackGuids, bool anyInstanceLive); } // namespace reasampler