// bank_ops.cpp — the promptless bank-verb seam (Q-W6 lift; see bank_ops.h for the // contract). The ONE implementation home of the bank verbs (create / rename / // delete / evacuate / activate / move / copy / remove): each mutates the given // session's book() then persists via persistBankOp() (one bank op = one Ctrl-Z; a // true index no-op opens NO undo point). It DOES mutate the bank BOOK — but only // the index/model + ext-state, never the arrange, never a sample file on disk // (bank ops are index-only; files stay put — CONTEXT.md §Multi-bank). // REFERENCE-INVALIDATION GUARDRAIL: after a STRUCTURAL mutation any // Bank*/BankModel& is invalid — verbs take ids and resolve fresh per model call. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are // extern (CLAUDE.md §contract). DAW-verified, not unit tested. #include "shell/bank_ops/bank_ops.h" #include #include #include "core/model/bank_book.h" // BankBook / TransferResult / RemoveScope #include "shell/persist/session.h" // ReaSamplerSession — the session the verbs mutate #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_genGuid #define REAPERAPI_WANT_guidToString #define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler { namespace { // Mints a fresh, genuine REAPER GUID string as a stable bank id (the B2/model // design: ids are caller-supplied and stable; the model stays pure and mints none). // Distinct from a track GUID by origin only — both are canonical guidToString output. std::string mintBankId() { GUID g{}; genGuid(&g); char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract) guidToString(&g, buf); return std::string(buf); } } // namespace // Persists a completed bank-index verb as a SINGLE batched REAPER undo point (R-B) — // one bank op = one Ctrl-Z. // // WHY THIS WRAPS AND saveToActiveProject() DOES NOT: a bank verb mutates ONLY our // project ext-state (SetProjExtState under "reasampler"), which REAPER's undo system // captures iff UNDO_STATE_MISCCFG is set in the Undo_EndBlock2 flags — the SDK // documents MISCCFG as covering "extensions!" project ext-state (reaper_plugin.h // ~1544, ~1199). We pass exactly UNDO_STATE_MISCCFG (not -1 / UNDO_STATE_ALL as the // item-move family does): a bank verb touches no tracks, FX, items, or envelopes, so // snapshotting them would be both heavier and semantically wrong. The persist runs // INSIDE the block so the post-mutation ext-state is the block's "after" image. // // UNSAVED-PROJECT GUARDRAIL: on an unsaved / no-active project saveToActiveProject() // no-ops (nothing is written to ext state). We must still CLOSE the block we opened, // but with an EMPTY label and a zero flag so REAPER DISCARDS the point instead of // recording a no-effect undo entry — mirroring view.cpp's empty-plan close. The // in-session model change stands and persists on the user's next save; it just earns // no undo point until there is a project to persist into (undo of an unsaved bank op // has nothing to roll back to anyway). The Begin/End must still be balanced, hence // the close-either-way. (Quiet persist by design — mirrors the CAPTURE path, NOT the // Design-View path; deliberately NO Save-As prompt.) void persistBankOp(ReaSamplerSession& session, const char* label, bool bumpGeneration) { Undo_BeginBlock2(nullptr); // S9: bump the bank-generation counter INSIDE the block, before the persist, so the // fresh generation rides the same ext-state write (saveToActiveProject() stamps // bankGeneration()). Bumped only for content-changing verbs (the caller decides); a // pure-organizational verb passes false and leaves the counter be, so a // rename/activate does not needlessly refresh live instances. if (bumpGeneration) session.bumpBankGeneration(); const bool persisted = session.saveToActiveProject(); if (persisted) Undo_EndBlock2(nullptr, label, UNDO_STATE_MISCCFG); else Undo_EndBlock2(nullptr, "", 0); // no ext-state write -> discard the empty point } // --- Promptless inner bank verbs (one home) ------------------------------------ // Model op + persistBankOp only; NO UX. Callers own prompts/confirms/nudges and the // session-liveness question. Each verb persists ONLY after the model accepted — a // rejected op opens no undo point. std::string bankOpCreate(ReaSamplerSession& session, const std::string& name) { const std::string id = mintBankId(); if (!session.book().createBank(id, name)) return {}; // duplicate display name (model rule) persistBankOp(session, "ReaSampler: create bank"); return id; } bool bankOpRename(ReaSamplerSession& session, const std::string& bankId, const std::string& newName) { if (!session.book().renameBank(bankId, newName)) return false; // pool / name in use persistBankOp(session, "ReaSampler: rename bank"); return true; } bool bankOpDelete(ReaSamplerSession& session, const std::string& bankId, bool bumpGeneration) { if (!session.book().deleteBank(bankId)) return false; // pool un-deletable (model rule) persistBankOp(session, "ReaSampler: delete bank", bumpGeneration); return true; } bool bankOpEvacuate(ReaSamplerSession& session, const std::string& bankId) { if (!session.book().evacuate(bankId)) return false; // pool is a destination, not a source // S9: evacuate moves members between banks (bank membership changes) -> bump. persistBankOp(session, "ReaSampler: evacuate bank", /*bumpGeneration=*/true); return true; } bool bankOpActivate(ReaSamplerSession& session, const std::string& bankId) { if (!session.book().setActiveBank(bankId)) return false; // rejects an unknown id persistBankOp(session, "ReaSampler: activate bank"); return true; } // NO-OP GUARDRAIL — VERB-AWARE (a collapse means different things per verb): // * MOVE collapse: the source entry WAS removed (bank_book moveSample removes // unconditionally before the dest add collapses on hash), so the index DID // mutate — it counts toward opening an undo point. // * COPY collapse: the source is left intact AND the dest already held the hash, // so NOTHING changed — a true index no-op. It must NOT open an undo point. // Hence: copy counts only real gains; move counts gains OR collapses. Ids pass // straight to the model op — no BankModel& cached across the loop's mutations. bool bankOpTransfer(ReaSamplerSession& session, const std::vector& sampleIds, const std::string& srcBankId, const std::string& destBankId, bool copy) { BankBook& b = session.book(); if (sampleIds.empty() || srcBankId == destBankId) return false; if (!b.bank(srcBankId) || !b.bank(destBankId)) return false; int ok = 0, collapsed = 0; for (const std::string& sid : sampleIds) { const TransferResult r = copy ? b.copySample(sid, srcBankId, destBankId) : b.moveSample(sid, srcBankId, destBankId); switch (r) { case TransferResult::Moved: case TransferResult::Copied: ++ok; break; case TransferResult::Collapsed: ++collapsed; break; case TransferResult::RejectedUnknownBank: case TransferResult::RejectedSampleAbsent: case TransferResult::RejectedSameBank: break; } } const bool mutated = copy ? (ok > 0) : (ok > 0 || collapsed > 0); if (!mutated) return false; // nothing changed — no persist, no undo point // S9: a move/copy changes bank membership (a sample arrives in / leaves a bank an // instance may reference) -> bump so assigned instances refresh hands-free. persistBankOp(session, copy ? "ReaSampler: copy sample(s)" : "ReaSampler: move sample(s)", /*bumpGeneration=*/true); return true; } // Index-only, this-bank scope (fork R-A: the sole surfaced verb; RemoveScope::AllBanks // stays latent in the model). Non-destructive to the file: a last-reference remove // leaves the file on disk, orphaned until Phase R prune — remove NEVER deletes bytes // (the manifest is untouched). Silent: recoverability is the batched undo (R-B). bool bankOpRemove(ReaSamplerSession& session, const std::vector& sampleIds, const std::string& srcBankId) { BankBook& b = session.book(); if (sampleIds.empty() || !b.bank(srcBankId)) return false; int removed = 0; for (const std::string& sid : sampleIds) if (b.removeSample(sid, srcBankId, RemoveScope::ThisBank) == RemoveResult::Removed) ++removed; if (removed == 0) return false; // every id already absent — no undo point // S9: a remove drops a sample from a bank (an instance referencing it must refresh — // it will resolve to silence, per the stale-id policy) -> bump. persistBankOp(session, "ReaSampler: remove sample(s)", /*bumpGeneration=*/true); return true; } } // namespace reasampler