Files
reasampler/src/shell/persist/ext_state_io.cpp
T
daniel 1c8709e82d Take the read-back back out of the persist; a write verdict belongs only where evidence crosses the plugin boundary
Its false gated six undo points, so an unverified byte-equality assumption could have silently removed Ctrl-Z for a bank mutation that landed.
2026-08-02 13:42:51 -04:00

324 lines
14 KiB
C++

// ext_state_io.cpp — the ext-state <-> JSON serialization half of the persist
// seam (see session.h for the TU map, ext_state_io.h for the key contract):
// the session's save/load/assignment-request bridge, plus the shared
// persist_detail helpers the sibling TUs call.
//
// 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; here they are extern (CLAUDE.md §contract).
//
// Storage: SetProjExtState/GetProjExtState, namespace "reasampler". The whole
// BankBook (pool as bank-zero + named banks) is written under key "banks"
// (authoritative); the legacy single-bank key "bank_index" is retired —
// cleared on save, read only once to migrate a pre-multi-bank project into
// the pool. Ext state is stored inside the .rpp, so the banks travel with the
// project automatically; the physical bank folder does not, so a Save-As to a
// new directory relocates it (poll(), session.cpp).
//
// Non-destructive: this module writes only our own ext-state keys and moves
// only our own reasampler_bank/ folder.
#include "shell/persist/ext_state_io.h"
#include <filesystem>
#include <optional>
#include <string>
#include <system_error>
#include <utility>
#include <vector>
#include "shell/persist/persist_internal.h"
#include "shell/persist/session.h"
#include "core/capture/capture_paths.h" // projectDirOfRpp (pure path arithmetic)
#include "core/instrument/map/bank_sync.h" // parseBankGeneration / formatBankGeneration (SHARED with the instrument reader)
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (T2-04: the ONE grow-loop policy)
#include "core/version/app_version.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_MarkProjectDirty
#define REAPERAPI_WANT_SetProjExtState
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h"
namespace reasampler::persist_detail {
namespace fs = std::filesystem;
// idx=-1 is the current project tab. rppPathOut is empty for a never-saved
// project (the reliable unsaved sentinel); returns nullptr only with no active
// project at all.
void* readActiveProject(std::string& rppPathOut) {
std::vector<char> buf(4096, '\0');
ReaProject* proj = EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
rppPathOut.assign(buf.data());
return proj;
}
// NOT GetProjectPathEx, which returns the recording path, not the .rpp's own
// directory. Delegates to the pure projectDirOfRpp so both artifacts share one
// implementation.
std::string projectDirOf(const std::string& rppPath) {
return capture::projectDirOfRpp(rppPath);
}
std::string getProjExtStateString(void* proj, const char* ns, const char* key) {
using wire::GrowingExtStateRead;
const GrowingExtStateRead read = wire::readProjExtStateGrowing(
[&](char* buf, int cap) {
return GetProjExtState(static_cast<ReaProject*>(proj), ns, key, buf, cap);
});
switch (read.status) {
case GrowingExtStateRead::Status::Complete:
return read.value;
case GrowingExtStateRead::Status::Absent:
return {}; // absent / empty -> empty bank
case GrowingExtStateRead::Status::Overflow:
break;
}
ShowConsoleMsg(("ReaSampler: stored value for key '" + std::string(key) +
"' exceeds the 16 MB read ceiling -- ignoring (bank not "
"loaded).\n").c_str());
return {};
}
// guidToString wants a >=64-char destination.
std::string genProjectGuidString() {
GUID g{};
genGuid(&g);
char buf[64] = {0};
guidToString(&g, buf);
return std::string(buf);
}
// Returns the existing GUID, a freshly minted one, or "" for an unsaved
// project. Called from both prime and the Load branch so no path skips the mint.
std::string ensureProjectGuid(void* proj, const std::string& rppPath,
const std::string& currentGuid) {
if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID
if (!currentGuid.empty()) return currentGuid;
const std::string minted = genProjectGuidString();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtGuidKey, minted.c_str());
return minted;
}
// Copy, not move (non-destructive); overwrites existing files at the
// destination so a re-save is idempotent. Best-effort: filesystem errors are
// swallowed and reported to the console. Returns true if the copy ran.
bool relocateBankFolder(const std::string& oldBankDir,
const std::string& newBankDir) {
std::error_code ec;
if (!fs::exists(oldBankDir, ec) || !fs::is_directory(oldBankDir, ec)) {
return false; // nothing at the old location to relocate
}
if (oldBankDir == newBankDir) return false; // defensive; plan guards this too
fs::create_directories(newBankDir, ec);
fs::copy(oldBankDir, newBankDir,
fs::copy_options::recursive | fs::copy_options::overwrite_existing,
ec);
if (ec) {
ShowConsoleMsg(("ReaSampler: bank relocation to '" + newBankDir +
"' failed: " + ec.message() + "\n").c_str());
return false;
}
return true;
}
} // namespace reasampler::persist_detail
namespace reasampler {
using persist_detail::getProjExtStateString;
using persist_detail::readActiveProject;
bool ReaSamplerSession::saveToActiveProject() {
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
const std::string banksJson = book_.serialize();
SetProjExtState(static_cast<ReaProject*>(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, "");
// 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());
const std::string tailJson = capture::serializeTailSetting(tail_);
SetProjExtState(static_cast<ReaProject*>(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());
}
// 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());
// 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,
instrument::map::formatBankGeneration(bankGeneration_).c_str());
MarkProjectDirty(static_cast<ReaProject*>(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
// what a false has to keep meaning to its callers, and why.
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
// 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));
return true;
}
namespace {
// Absent/empty key -> default-constructed model, graceful, never a crash.
// Malformed JSON is warned and also falls back to default.
ViewModeModel loadViewModel(ReaProject* proj) {
if (!proj) return ViewModeModel{};
const std::string viewJson =
getProjExtStateString(proj, projExtNamespace(), kProjExtViewKey);
if (viewJson.empty()) return ViewModeModel{}; // no stored view state -> default
std::optional<ViewModeModel> loaded = ViewModeModel::deserialize(viewJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored view state is malformed -- ignoring.\n");
return ViewModeModel{};
}
return std::move(*loaded);
}
// Absent/empty key -> default (None / 2 s manual). Malformed JSON warns and
// falls back to default.
capture::TailSetting loadTailSetting(ReaProject* proj) {
if (!proj) return capture::TailSetting{};
const std::string tailJson =
getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey);
if (tailJson.empty()) return capture::TailSetting{}; // no stored setting -> default
std::optional<capture::TailSetting> loaded =
capture::deserializeTailSetting(tailJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored tail setting is malformed -- ignoring.\n");
return capture::TailSetting{};
}
return *loaded;
}
// Absent/empty key -> Fresh (a new project, or a bank predating the ledger). Anything
// this build cannot read is a degraded status, never an empty ledger.
tracking::LedgerLoad loadOriginLedger(ReaProject* proj) {
if (!proj) return tracking::LedgerLoad{};
tracking::LedgerLoad load = tracking::loadLedger(
getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey));
if (load.status == tracking::LedgerStatus::Unreadable) {
ShowConsoleMsg("ReaSampler: the stored file-tracking ledger is malformed. Prune "
"is halted for this project and the stored value is left intact "
"for recovery.\n");
} else if (load.status == tracking::LedgerStatus::FutureVersion) {
ShowConsoleMsg("ReaSampler: the stored file-tracking ledger was written by a "
"NEWER version of ReaSampler. Prune is halted for this project "
"and the stored value will not be overwritten -- reopen the "
"project with that version.\n");
}
return load;
}
} // namespace
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
// loadFromProject is the single choke point for every load path (prime,
// project switch/open, forked-sibling load) — NOT the Save-As branch,
// which keeps the in-memory model as-is. main.cpp drains this via
// consumeLoadSignal() on the same tick.
loadPending_ = true;
// view_/tail_/tracking_ are all restored on EVERY load path: switching to a
// project with no stored state must reset to default, never inherit the
// previous project's. An undo/redo reload must re-read the restored
// values so they match the rolled-back state.
view_ = loadViewModel(static_cast<ReaProject*>(proj));
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
tracking::LedgerLoad ledger = loadOriginLedger(static_cast<ReaProject*>(proj));
trackingStatus_ = ledger.status;
tracking_ = std::move(ledger.ledger);
// An absent stamp classifies as PreVersioning, a malformed one as Unknown
// — both silent. proj == nullptr -> "" -> default.
writingVersion_ = version::classifyWritingVersion(
proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtVersionKey)
: std::string{});
// Continues monotonic from the stored value rather than resetting to 0 on
// reopen; absent/malformed parses to 0 via the shared decoder.
bankGeneration_ = instrument::map::parseBankGeneration(
proj ? getProjExtStateString(proj, projExtNamespace(), kProjExtBankGenKey)
: std::string{});
if (!proj) {
book_ = BankBook{};
return;
}
// `banks` is authoritative when present; a malformed blob degrades to an
// empty book rather than falling back to the stale legacy key (which
// would resurrect superseded single-bank state).
const std::string banksJson =
getProjExtStateString(proj, projExtNamespace(), kProjExtBanksKey);
if (!banksJson.empty()) {
std::optional<BankBook> loaded = BankBook::deserialize(banksJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored banks are malformed -- ignoring.\n");
book_ = BankBook{};
} else {
book_ = std::move(*loaded);
}
} else {
// No `banks` yet — migrate the legacy `bank_index` into the pool.
const std::string legacyJson =
getProjExtStateString(proj, projExtNamespace(), kProjExtIndexKey);
book_ = BankBook::loadFromPersisted(std::string{}, legacyJson);
}
// Seed each bank's display-position SlotMap from insertion order when the
// loaded blob carried none, and reconcile a partial map. Idempotent.
book_.reconcileSlots();
// Paths stay relative (read-time resolution is the consumers' job);
// nothing to do here beyond replacing the in-memory book.
(void)projectDir;
}
} // namespace reasampler