1c8709e82d
Its false gated six undo points, so an unverified byte-equality assumption could have silently removed Ctrl-Z for a bank mutation that landed.
324 lines
14 KiB
C++
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
|