Narrow the mode switch's persist to the one key it changes

A switch wrote all seven ext-state keys to record that view_state moved,
re-serializing the bank book and the tracking ledger for nothing. Both entry
surfaces now take saveViewStateOnly; every other caller keeps the full save.
This commit is contained in:
2026-08-03 12:39:28 -04:00
parent 0eb2c67875
commit 82a8d51004
4 changed files with 74 additions and 35 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ REAPER/filesystem-facing half only, and it gathers rather than decides.
## Modules
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `saveToActiveProject` returns whether the writes were ISSUED — false means no active/saved project and NOTHING was written, which is the only reading its callers' discard-the-undo-point branch is safe under; it must never grow an observational third failure mode (the contract lives at its declaration in `session.h`). `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `saveToActiveProject` returns whether the writes were ISSUED — false means no active/saved project and NOTHING was written, which is the only reading its callers' discard-the-undo-point branch is safe under; it must never grow an observational third failure mode (the contract lives at its declaration in `session.h`). `saveViewStateOnly` is its narrowed sibling for a caller that changed only the Design-View model — `view_state` plus the dirty mark, no other key — and returns on that same rule; the full save stays the default and narrowing is opt-in per call site. `session` additionally owns `recordCreated` — **the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) — and, beside them, `tiedUsageFor`, the resample's replace-vs-add input, deliberately co-located so "both answers come out of one `TrackingState`" is structural rather than a rule two files must remember. It is also **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
+45 -26
View File
@@ -137,51 +137,61 @@ namespace reasampler {
using persist_detail::getProjExtStateString;
using persist_detail::readActiveProject;
bool ReaSamplerSession::saveToActiveProject() {
namespace {
// The ONE guard behind every ext-state write entry point here, so the two conditions
// that produce a false stay a single rule rather than a copy per entry point.
// session.h owns what that false has to mean to callers.
ReaProject* activeSavedProject() {
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj) return false; // no active project — nothing to persist
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
if (!proj || rppPath.empty()) return nullptr;
return static_cast<ReaProject*>(proj);
}
void writeViewState(ReaProject* proj, const ViewModeModel& view) {
const std::string viewJson = view.serialize();
SetProjExtState(proj, projExtNamespace(), kProjExtViewKey, viewJson.c_str());
}
} // namespace
bool ReaSamplerSession::saveToActiveProject() {
ReaProject* proj = activeSavedProject();
if (!proj) return false;
const std::string banksJson = book_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtBanksKey, banksJson.c_str());
SetProjExtState(proj, projExtNamespace(), kProjExtBanksKey, banksJson.c_str());
// Retire the legacy single-bank key: SetProjExtState with an empty value
// deletes it. Idempotent when already absent.
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtIndexKey, "");
SetProjExtState(proj, projExtNamespace(), kProjExtIndexKey, "");
// Each of the following rides in its own key, independent of `banks`.
const std::string viewJson = view_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtViewKey, viewJson.c_str());
writeViewState(proj, view_);
const std::string tailJson = capture::serializeTailSetting(tail_);
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtTailKey, tailJson.c_str());
SetProjExtState(proj, projExtNamespace(), kProjExtTailKey, tailJson.c_str());
// Never write over a blob this build could not read (see this directory's
// CLAUDE.md for why the suppression, not a rewrite, is the safe direction).
if (!tracking::ledgerDegraded(trackingStatus_)) {
const std::string ledgerJson = tracking_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtOwnedKey, ledgerJson.c_str());
SetProjExtState(proj, projExtNamespace(), kProjExtOwnedKey, ledgerJson.c_str());
}
// stampVersion() (not appVersion()) is the numeric triple only, no "-beta"
// suffix, so the stamp is byte-identical to stable regardless of channel
// — the channel is already carried by the isolated namespace.
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtVersionKey, version::stampVersion().c_str());
SetProjExtState(proj, projExtNamespace(), kProjExtVersionKey,
version::stampVersion().c_str());
// Whatever bumpBankGeneration() advanced the counter to since the last
// save (0 if never bumped). Shared encoder so writer/reader agree byte-for-byte.
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtBankGenKey,
SetProjExtState(proj, projExtNamespace(), kProjExtBankGenKey,
instrument::map::formatBankGeneration(bankGeneration_).c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
MarkProjectDirty(proj);
// The writes were ISSUED into a saved active project — all this call can observe, and
// deliberately all it claims. Do not "prove" them with a read-back; session.h states
@@ -189,17 +199,26 @@ bool ReaSamplerSession::saveToActiveProject() {
return true;
}
// The version stamp and the degraded-ledger suppression deliberately do NOT appear
// here: neither the bank book nor the ledger is rewritten, so there is nothing for
// this build to claim authorship of and nothing to suppress.
bool ReaSamplerSession::saveViewStateOnly() {
ReaProject* proj = activeSavedProject();
if (!proj) return false;
writeViewState(proj, view_);
MarkProjectDirty(proj);
return true;
}
bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) {
std::string rppPath;
void* proj = readActiveProject(rppPath);
if (!proj) return false; // no active project — nothing to signal
if (rppPath.empty()) return false; // unsaved project — no .rpp to store into
ReaProject* proj = activeSavedProject();
if (!proj) return false;
// One-shot write under its own key: a transient signal to the instrument,
// not session state that rides every save.
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtAssignKey, wire.c_str());
MarkProjectDirty(static_cast<ReaProject*>(proj));
SetProjExtState(proj, projExtNamespace(), kProjExtAssignKey, wire.c_str());
MarkProjectDirty(proj);
return true;
}
+13
View File
@@ -131,6 +131,19 @@ public:
// remove the Ctrl-Z for a bank mutation that landed.
bool saveToActiveProject();
// The narrowed sibling, for a caller that changed ONLY the Design-View model:
// writes `view_state`, marks the project dirty, and touches no other key. The
// full save stays the default — this is opt-in per call site.
//
// Opt-in rather than a per-key dirty flag on the session: a call site that
// forgets to opt in merely pays the old cost, whereas a mutation site that
// forgets to mark its key dirty would silently stop persisting it, and the
// dirty flags would have to be threaded through every writer of book_/tail_/
// tracking_ across the capture, bank-op and ingest layers.
//
// Returns on the SAME rule as saveToActiveProject above, prohibition included.
bool saveViewStateOnly();
// Report-only prune dry-run: feeds the pure core with (present, referenced,
// owned) — `present` from the folder enumeration, the other two from the
// tracking authority. FAIL-SAFE: tracking state the authority cannot read