pS-usage: captures held by live ReaSampler 9000 instances are un-prunable — instances publish usage_<guid> ext-state records (ComponentState v11), prune unions live holds into referenced

This commit is contained in:
2026-07-28 12:56:36 -04:00
parent f988ded543
commit 5886ae1456
19 changed files with 1282 additions and 10 deletions
+240
View File
@@ -0,0 +1,240 @@
// usage_scan.cpp — see usage_scan.h. The REAPER reads behind the pS-usage prune
// protection; every decision is in the pure sample_usage module, this TU only reads.
//
// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h
// WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the one TU that defines the API pointers
// (CLAUDE.md §contract). Every REAPER symbol used here is verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
// * EnumProjExtState(proj, extname, idx, keyOut, sz, valOut, sz) -> bool (~1272)
// * GetProjExtState(proj, extname, key, valOut, sz) -> int (~2591)
// * CountTracks / GetTrack / GetMasterTrack (track scan)
// * TrackFX_GetCount(MediaTrack*) / TrackFX_GetRecCount(MediaTrack*) (~7283/7570)
// * TrackFX_GetNamedConfigParm(MediaTrack*, int, parm, buf, sz) -> bool (~7377)
// * CountMediaItems / GetMediaItem (~423/1964)
// * CountTakes(MediaItem*) / GetMediaItemTake(MediaItem*, int) (~471/2029)
// * GetMediaItemTrack(MediaItem*) (~2133)
// * TakeFX_GetCount / TakeFX_GetNamedConfigParm (~6710/6774)
// * guidToString (via track_guid::guidString)
#include "usage_scan.h"
#include <cctype>
#include <cstdlib>
#include <string>
#include <unordered_set>
#include <vector>
#include "app_version.h" // vstPluginName (channel display-name fallback match)
#include "ext_keys.h" // kProjExtNamespace / kProjExtUsageKeyPrefix
#include "instrument_drop.h" // vstClassIdHex — the frozen channel class-UID hex
#include "sample_usage.h" // decodeUsageRecord, usageHeldPaths (the pure decisions)
#include "track_guid.h" // guidString — the ONE canonical GUID key formatter
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjExtState
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMasterTrack
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetRecCount
#define REAPERAPI_WANT_TrackFX_GetNamedConfigParm
#define REAPERAPI_WANT_CountMediaItems
#define REAPERAPI_WANT_GetMediaItem
#define REAPERAPI_WANT_CountTakes
#define REAPERAPI_WANT_GetMediaItemTake
#define REAPERAPI_WANT_GetMediaItemTrack
#define REAPERAPI_WANT_TakeFX_GetCount
#define REAPERAPI_WANT_TakeFX_GetNamedConfigParm
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
std::string toUpperAscii(const std::string& s) {
std::string out = s;
for (char& c : out)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return out;
}
// Does this FX identity string name a ReaSampler 9000 of THIS channel? Primary match:
// fx_ident contains the channel's 32-hex class UID (REAPER renders VST3 idents with the
// UID hex embedded; case varies, so compare uppercased). Fallback: the identity carries
// the channel display name ("ReaSampler 9000" / "ReaSampler 9000 beta") — belt and
// braces for an fx_ident rendering that omits the hex. A false positive here only
// widens the protected set (fail-safe direction); it can never cause a delete.
bool identityMatches(const std::string& identity, const std::string& uidHexUpper,
const std::string& nameUpper) {
if (identity.empty()) return false;
const std::string up = toUpperAscii(identity);
if (!uidHexUpper.empty() && up.find(uidHexUpper) != std::string::npos) return true;
return !nameUpper.empty() && up.find(nameUpper) != std::string::npos;
}
// Read one named config parm of a track FX into a string ("" on failure/absence).
std::string trackFxParm(MediaTrack* tr, int fxId, const char* parm) {
char buf[2048] = {0};
if (!TrackFX_GetNamedConfigParm(tr, fxId, parm, buf, static_cast<int>(sizeof(buf))))
return {};
return std::string(buf);
}
// True if any FX in the (possibly container-nested) sub-chain rooted at `fxId` is a
// ReaSampler 9000. Containers are walked via the documented container_count /
// container_item.X addressing (v7.06+); `depth` bounds pathological nesting. fx_ident
// is queried per FX — chain enumeration is chunk-level, so OFFLINE instances match too
// (load-bearing: a Design-View-parked instance must keep protecting its holds).
bool trackFxSubtreeHasInstance(MediaTrack* tr, int fxId,
const std::string& uidHexUpper,
const std::string& nameUpper, int depth) {
if (identityMatches(trackFxParm(tr, fxId, "fx_ident"), uidHexUpper, nameUpper) ||
identityMatches(trackFxParm(tr, fxId, "original_name"), uidHexUpper, nameUpper))
return true;
if (depth <= 0) return false;
const std::string countStr = trackFxParm(tr, fxId, "container_count");
if (countStr.empty()) return false; // not a container
const int n = std::atoi(countStr.c_str());
for (int k = 0; k < n; ++k) {
const std::string item =
trackFxParm(tr, fxId, ("container_item." + std::to_string(k)).c_str());
if (item.empty()) continue;
const int childId = std::atoi(item.c_str());
if (childId <= 0) continue;
if (trackFxSubtreeHasInstance(tr, childId, uidHexUpper, nameUpper, depth - 1))
return true;
}
return false;
}
// True if `tr` hosts >= 1 ReaSampler 9000 anywhere: normal chain, record/input chain
// (index | 0x1000000), containers recursively.
bool trackHasInstance(MediaTrack* tr, const std::string& uidHexUpper,
const std::string& nameUpper) {
constexpr int kMaxContainerDepth = 8;
const int n = TrackFX_GetCount(tr);
for (int i = 0; i < n; ++i) {
if (trackFxSubtreeHasInstance(tr, i, uidHexUpper, nameUpper, kMaxContainerDepth))
return true;
}
const int rec = TrackFX_GetRecCount(tr);
for (int i = 0; i < rec; ++i) {
if (trackFxSubtreeHasInstance(tr, 0x1000000 + i, uidHexUpper, nameUpper,
kMaxContainerDepth))
return true;
}
return false;
}
// True if any take FX on `item` is a ReaSampler 9000 (all takes, not just active — a
// non-active take's instance still exists in the project and reactivates with the
// take). No container recursion here: take chains are queried flat, and a sampler
// nested in a take-FX container is exotic enough that the empty-trackGuid any-instance
// fallback (sample_usage liveness rule) is the documented safety net.
bool itemHasInstance(MediaItem* item, const std::string& uidHexUpper,
const std::string& nameUpper) {
const int takes = CountTakes(item);
for (int t = 0; t < takes; ++t) {
MediaItem_Take* take = GetMediaItemTake(item, t);
if (!take) continue;
const int n = TakeFX_GetCount(take);
for (int i = 0; i < n; ++i) {
char buf[2048] = {0};
if (TakeFX_GetNamedConfigParm(take, i, "fx_ident", buf,
static_cast<int>(sizeof(buf))) &&
identityMatches(buf, uidHexUpper, nameUpper))
return true;
}
}
return false;
}
// Growing GetProjExtState read (the persist.cpp idiom): the usage record scales with
// the hold count, so a fixed buffer risks a truncated decode — and an undecodable
// record protects nothing, which is the DANGEROUS direction here. Empty on absence.
std::string readExtStateValue(ReaProject* proj, const char* key) {
for (int cap = 1 << 12; cap <= (1 << 24); cap <<= 2) {
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
const int rv = GetProjExtState(proj, kProjExtNamespace(), key, buf.data(), cap);
if (rv <= 0) return {};
std::string s(buf.data());
if (static_cast<int>(s.size()) + 1 < cap) return s;
// else possibly truncated -> grow and retry
}
return {};
}
} // namespace
std::vector<std::string> liveInstanceHeldPaths(void* projOpaque) {
ReaProject* proj = static_cast<ReaProject*>(projOpaque);
// 1. Enumerate the usage_* keys and decode each record. Key names first (values via
// the growing reader — EnumProjExtState's fixed val buffer could truncate a large
// record, and a truncated record decodes to nothing = protects nothing).
std::vector<std::string> usageKeys;
{
char keyBuf[256];
for (int idx = 0;; ++idx) {
keyBuf[0] = '\0';
if (!EnumProjExtState(proj, kProjExtNamespace(), idx, keyBuf,
static_cast<int>(sizeof(keyBuf)), nullptr, 0))
break;
const std::string key(keyBuf);
const std::string prefix = kProjExtUsageKeyPrefix;
if (key.compare(0, prefix.size(), prefix) == 0) usageKeys.push_back(key);
}
}
std::vector<UsageRecord> records;
records.reserve(usageKeys.size());
for (const std::string& key : usageKeys) {
const std::string value = readExtStateValue(proj, key.c_str());
if (value.empty()) continue;
if (std::optional<UsageRecord> rec = decodeUsageRecord(value)) {
records.push_back(std::move(*rec));
}
}
if (records.empty()) return {}; // no instance ever published — skip the FX scan
// 2. Enumerate live ReaSampler 9000 hosts. One channel-frozen identity pair drives
// every match; a track needs only ONE instance to keep all its records live.
const std::string uidHexUpper = toUpperAscii(vstClassIdHex());
const std::string nameUpper = toUpperAscii(vstPluginName());
std::unordered_set<std::string> liveTrackGuids;
bool anyLive = false;
if (MediaTrack* master = GetMasterTrack(proj)) {
if (trackHasInstance(master, uidHexUpper, nameUpper)) {
liveTrackGuids.insert(guidString(master));
anyLive = true;
}
}
const int trackCount = CountTracks(proj);
for (int i = 0; i < trackCount; ++i) {
MediaTrack* tr = GetTrack(proj, i);
if (!tr) continue;
if (trackHasInstance(tr, uidHexUpper, nameUpper)) {
liveTrackGuids.insert(guidString(tr));
anyLive = true;
}
}
// Take-FX instances: attributed to the owning track (the VST-side getReaperParent(1)
// resolves the same track), and they set anyLive for the empty-guid fallback.
const int itemCount = CountMediaItems(proj);
for (int i = 0; i < itemCount; ++i) {
MediaItem* item = GetMediaItem(proj, i);
if (!item) continue;
if (itemHasInstance(item, uidHexUpper, nameUpper)) {
if (MediaTrack* tr = GetMediaItemTrack(item)) {
liveTrackGuids.insert(guidString(tr));
}
anyLive = true;
}
}
// 3. The pure liveness fold decides which records count.
return usageHeldPaths(records, liveTrackGuids, anyLive);
}
} // namespace reasampler