#pragma once // capture_paths — the REAPER-free path arithmetic behind offline capture. // // PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO // vendor/ includes. Standard library only. The capture shell resolves the // current project directory via REAPER APIs, then hands the raw strings here so // the fiddly, easy-to-get-wrong path arithmetic (bank subfolder, unique file // name, absolute render dir, project-relative index path) is unit-tested outside // the DAW. // // Path convention: this module works in forward-slash form and does NOT touch // the filesystem. The bank subfolder name is a fixed constant so the same // project always resolves the same bank location (determinism). #include namespace reasampler { // The project-relative bank subfolder. All captured wavs live here so the bank // travels with the .rpp (CONTEXT.md §Settled decisions: per-project bank). inline constexpr const char* kBankSubfolder = "reasampler_bank"; // A resolved pair of paths for one capture: where REAPER must be told to write // (absolute, because RENDER_FILE wants a directory REAPER can create/open) and // what we store in the BankIndex (project-relative, because the index is // relative-paths-only — CLAUDE.md precision invariant). struct BankPaths { std::string absoluteDir; // /reasampler_bank (forward slash) std::string relativePath; // reasampler_bank/ (index value) std::string fileName; // .wav (full file name) std::string fileStem; // (RENDER_PATTERN — REAPER appends the extension) }; // Normalizes a path to forward slashes and strips any trailing slash. Empty in // -> empty out. Pure string transform (does not consult the filesystem). std::string normalizeSlashes(const std::string& path); // Sanitizes a caller-supplied base name into a filesystem-safe stem: keeps // [A-Za-z0-9._-], replaces every other byte (spaces, slashes, quotes, control) // with '_', and collapses to "capture" if nothing usable remains. Deterministic: // the same input always yields the same stem (feeds bit-identical file naming). std::string sanitizeStem(const std::string& baseName); // Derives the bank paths for one capture. // projectDir : absolute directory of the current .rpp (any slash style) // baseName : human base for the file stem (sanitized) // uniqueTag : caller-supplied disambiguator appended to the stem (e.g. a // timestamp or counter) so repeated captures do not collide. // Also sanitized. May be empty. // Produces "[_].wav". The relativePath is always project-relative and // forward-slashed so it satisfies BankIndex::add's relative-only invariant. BankPaths deriveBankPaths(const std::string& projectDir, const std::string& baseName, const std::string& uniqueTag); // --- Persist-side path arithmetic (M4) -------------------------------------- // // The index stores relative paths only; on project load the persist shell must // turn each entry's relativePath back into an absolute path against the CURRENT // project directory (so a project opened from a new location still resolves its // bank). This is the inverse of the relativePath the capture path produced. // // projectDir : absolute directory of the current .rpp (any slash style) // relativePath : a project-relative index entry (e.g. "reasampler_bank/x.wav") // // Returns "/" forward-slashed. Returns empty when // either input is empty (no default-location fallback — CLAUDE.md invariant) so // a caller that ignores an unsaved/unset project fails loudly rather than // resolving against CWD. std::string resolveBankFile(const std::string& projectDir, const std::string& relativePath); // A relocation plan for the physical bank folder on Save-As to a new project // location. The index's relative paths do NOT change (they are relative to the // project dir, which is what moved with the .rpp), so relocation is purely a // folder move: copy/move the whole bank subfolder from the old project dir to // the new one. Both dirs are absolute, forward-slashed, trailing-slash-stripped. struct BankRelocation { std::string oldBankDir; // /reasampler_bank std::string newBankDir; // /reasampler_bank bool needed = false; // false when old==new (Save in place, not Save-As) }; // Derives the relocation plan from the old and new project directories. // oldProjectDir : project dir the bank currently sits under (any slash style) // newProjectDir : project dir the .rpp was just saved to (any slash style) // `needed` is true iff the normalized dirs differ (a genuine Save-As-to-new-dir). // Returns a plan with empty dirs and needed=false when either input is empty. BankRelocation deriveRelocationPlan(const std::string& oldProjectDir, const std::string& newProjectDir); // --- Project-identity transition (W12 combined identity fix) ----------------- // // What the persist timer must do on each tick. Identity rests on TWO facts, // layered GUID-PRIMARY: // 1. the minted GUID — content-based identity of record, stored in ext state. // It is IMMUNE to REAPER recycling a closed project's ReaProject* address, // so it is checked FIRST. // 2. sameProjectObject — did the same live ReaProject* stay active across the // two ticks (computed in poll() as `proj == lastProject_`)? Used ONLY to // disambiguate the same-GUID case: a forked sibling (Save-As copied our GUID // onto a distinct object) vs a genuine Save-As (one object, new path). // // This fix layers both prior designs, GUID-primary. M4 (GUID-only) broke Save-As // forks: Save-As copies the whole .rpp incl. our stored GUID, so a fork and its // parent share a GUID on disk. W10 (pointer-primary, GUID voided) broke pointer // RECYCLING: REAPER reuses a closed project's address, so a reopened/new project // can present the previous project's pointer with a different stored GUID — // pointer-primary read that as NoOp/SaveAsRelocate and the bank never reloaded. // Checking the GUID first catches recycling; the pointer then separates a fork // (same GUID, different object -> Load) from a Save-As (same GUID, same object, // new path -> relocate). // // The load-bearing rule: a DIFFERENT record identity (GUID) is always a Load; a // DIFFERENT project object with the same GUID is a fork Load, never a relocate. enum class ProjectTransition { NoOp, // same object, same GUID, same location — nothing to do Load, // a different project is active — load ITS index from ext state SaveAsRelocate, // SAME object + SAME GUID, new .rpp location — relocate the bank }; // Classifies what a poll tick observed. // sameProjectObject : true iff the SAME ReaProject* stayed active across the two // ticks (poll() computes `proj == lastProject_`). The pure // classifier takes the bool, not the raw pointer, to stay // REAPER-free and testable. // lastGuid : the GUID of the project persist last acted on ("" if none/unsaved) // lastPath : that project's .rpp path when last seen ("" if unsaved) // currentGuid : the GUID stored in the now-active project's ext state ("" if // unsaved or never written) // currentPath : the now-active project's .rpp path ("" if unsaved) // // Rules (evaluated in EXACTLY this order): // 1. currentGuid != lastGuid -> Load (different record identity: // recycled pointer w/ different GUID, // new/unsaved<->saved, or two distinct // saved projects) // 2. !sameProjectObject -> Load (same GUID, different object: // forked sibling, or two unsaved projects) // 3. currentPath != lastPath -> SaveAsRelocate (same object + same GUID, // new path: genuine Save-As, or first save // of an unsaved project — relocate no-ops // on the empty old dir, poll() mints a GUID) // 4. otherwise -> NoOp (same object, same GUID, same path) // // The GUID (identity of record) leads; the pointer only disambiguates the same-GUID // case (fork-Load in step 2 vs Save-As in step 3). The empty-GUID safety (unsaved // projects never physically relocate) is preserved because an empty old project dir // makes deriveRelocationPlan's `needed` false. ProjectTransition classifyProjectTransition(bool sameProjectObject, const std::string& lastGuid, const std::string& lastPath, const std::string& currentGuid, const std::string& currentPath); } // namespace reasampler