// 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 #include #include #include #include #include #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 buf(4096, '\0'); ReaProject* proj = EnumProjects(-1, buf.data(), static_cast(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(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(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(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(proj), projExtNamespace(), kProjExtIndexKey, ""); // Each of the following rides in its own key, independent of `banks`. const std::string viewJson = view_.serialize(); SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtViewKey, viewJson.c_str()); const std::string tailJson = capture::serializeTailSetting(tail_); SetProjExtState(static_cast(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(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(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(proj), projExtNamespace(), kProjExtBankGenKey, instrument::map::formatBankGeneration(bankGeneration_).c_str()); MarkProjectDirty(static_cast(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(proj), projExtNamespace(), kProjExtAssignKey, wire.c_str()); MarkProjectDirty(static_cast(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 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 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(proj)); tail_ = loadTailSetting(static_cast(proj)); tracking::LedgerLoad ledger = loadOriginLedger(static_cast(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 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