From 86aa8dabbafe0687e17e843d68141179443d3a8d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 12:53:42 -0400 Subject: [PATCH 01/14] Collapse a Design View mode switch and its background FX parking to ONE undo point applyMode now returns with its undo block open; the reclaimer closes it when the drain settles. A transport deferral or a tab switch ends it early -- two points, never a leak. --- src/app/main.cpp | 4 + src/shell/view/CLAUDE.md | 71 +++++++++---- src/shell/view/fx_reclaim.cpp | 61 +++++++++-- src/shell/view/fx_reclaim.h | 135 +++++++++++++++++++++-- src/shell/view/view.cpp | 27 +++-- src/shell/view/view.h | 7 +- tests/test_fx_reclaim.cpp | 194 +++++++++++++++++++++++++++++++++- 7 files changed, 449 insertions(+), 50 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 554568b..2d7e60e 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -324,6 +324,10 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // altered transport/cursor in the user's project on unload. capture::AbortRealtimeCaptureForUnload(g_session); + // Same reason, for the reclaimer's undo span: a block left open here has no + // owner left to close it and would swallow every edit the user makes after. + reasampler::fxReclaimEndUndoSpan(); + g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-projectconfig", (void*)&g_projectConfig); g_rec->Register("-toggleaction", (void*)&OnToggleAction); diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index d20eff5..571d5d3 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -64,17 +64,35 @@ decide membership or mode rules. offline/online runs on the background reclaimer (`fx_reclaim`), in ticks bounded by `kOnlineBudgetMs`/`kOfflineBudgetMs`, off `applyMode`'s block entirely (measured: `TrackFX_SetOffline` was 1897ms of a 1942ms switch). -- **ONE mode switch is ONE undo point.** The driven flags and the lane state - written inside `applyMode`'s block roll back in a single Ctrl-Z. (The - `view_state` ext-state write is NOT among them; it lands after the block, see - Gotchas.) Per-FX offline/online moved off this block entirely, onto the - reclaimer (`fx_reclaim`) — each write there runs inside its OWN discarded - block (`Undo_EndBlock2(proj, "", 0)`), so it mints nothing. DAW-VERIFIED: ten - unblocked `TrackFX_SetOffline` calls minted ten undo points; the same ten - calls wrapped in `Undo_BeginBlock2`/`Undo_EndBlock2(proj, "", 0)` minted zero - — confirming the discard-block premise the reclaimer's per-write block - (`fx_reclaim.cpp`'s `writeOffline`) rests on. -- **A reapply that wrote nothing leaves no undo point.** The load-tick reapply +- **ONE mode switch is ONE undo point — the background FX parking included.** The + driven flags, the lane state, AND the per-FX offline/online the reclaimer writes + minutes-of-plugin-loads later all roll back in a single Ctrl-Z, because they share + ONE undo block: `applyMode` opens it (`fx_reclaim`'s `UndoSpan`) and a REAL switch + RETURNS WITH IT OPEN, for the reclaimer to close once the drain has converged. A + reapply closes its own before returning. This does not move an FX write back onto + the switch — the writes still run on the timer, off the 1897ms path. + DAW-MEASURED, and the reason the design is shaped this way: block WIDTH is the + entire effect. For ten `TrackFX_SetOffline` calls from a TIMER — unblocked, or + under a block per write, by every dispatch route — REAPER minted TEN points; under + ONE block spanning all ten it minted ONE. The `("", 0)` discard form does NOT + discard outside an action, it coalesces; it discards only from a hand-fired action. + A block opened on one timer callback and closed on a LATER one holds. Whether one + held from an ACTION into a later timer callback holds is the same property but is + `[verify — DAW]`; if it does not, the switch and its parking land as two points, + which is the intended degradation. +- **No path may return past a terminating condition with the span open.** A leaked + block swallows every edit the user makes afterwards, indefinitely, so every + terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the + drain settling, aborting on a generation bump, or deferring on the transport; the + project retargeting or closing; the next apply superseding it; a project save; + extension unload. The span may stay open only across a wait it can BOUND — the 3s + debounce, which is the dead time between the switch and its first FX write, and + the only reason the span exists. A TRANSPORT deferral is not bounded (it lasts as + long as the playback pass), so it ends the span and the parking that follows mints + its own point (`spanEndsAfter`). +- **A reapply that wrote nothing leaves no undo point** — and a reapply never holds + the span open, so a project open, a membership edit and `render_in_place`'s inner + reapply all keep the block shape they had. The load-tick reapply re-plans a park for every inactive leaf, and over a project saved fully parked it finds every flag and every FX already where the plan wants them. The block then closes on the `("", 0)` discard form (`view.h`'s `applyMintsUndoPoint`; @@ -124,13 +142,13 @@ applies the resulting lane state to live tracks. ## Modules -- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and owns the ONE undo block all of that rides (`applyMintsUndoPoint` decides whether it leaves a point). Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. +- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and OPENS the ONE undo span all of that rides (`applyMintsUndoPoint` decides whether it leaves a point; on a real switch `fx_reclaim` is what closes it). Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. - `view_park` — the park surface, FLAG DOMAIN ONLY. `parkTrack` captures the pre-park snapshot (flags AND the FX chain's identity-keyed offline state) when the model holds none, drives the four flags, and EXPOSEs a track that reads park-shaped with no record behind it rather than adopting it; `restoreTrack` writes the flags back from the snapshot and spends its flag half (`releaseSnapshotFlags`). Also `trackFlagParm` (the one `Flag` → REAPER-parameter mapping), `writeIfChanged` (the read-before-write every driven parameter goes through), `fxGuidString` (the one FX-identity read snapshot/reclaim both address FX through), `kViewUndoMask`, and `reportExposedTracks`/`describeExposedTracks` (named tracks, once per changed set per project). - `view_lanes` — managed fixed lanes: `mintManagedLanes` splits multi-mode content onto per-mode lanes, `applyLaneOps` drives `C_LANEPLAYS` for the active mode, `reconcileManagedLanes` recovers ownership from durable `P_LANENAME` on project open. Manual lanes are never minted, driven, renamed or reassigned. - `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). - `view_tracks` — the scaffolding every write pass sets up: `TrackHandles`/`TrackByGuid`, `readFolderEntries`/`indexHandles`, and `UiRefreshHold` (the `PreventUIRefresh` RAII pair every write phase runs under). - `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`. -- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump); each `writeOffline` runs inside its own discarded undo block, DAW-verified to mint nothing. `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. +- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. ## Gotchas @@ -158,15 +176,24 @@ applies the resulting lane state to live tracks. reads identically and is EXPOSEd too — no lost state there at all, which is why the report never asserts a cause. A REDO of a mode switch is a further, hypothetical route — see the `view_state` gotcha below. -- **An UNDO of a mode switch no longer rolls back FX at all.** FX offline/online - moved off `applyMode`'s block entirely onto the reclaimer, so a Ctrl-Z restores - only the flags and lane state the block wrote — whatever the reclaimer had - converged (or had not yet converged) survives the undo untouched. Whether this - reads as correct in the DAW is `[verify — DAW]`, `docs/VERIFICATION.md` - §"Mode switching". -- **The `view_state` write is NOT inside `applyMode`'s block.** `persistViewState` - (`design_view_actions.cpp`) runs after `applyMode` has returned, so - `SetProjExtState` lands past `Undo_EndBlock2`. The UNDO direction still comes +- **An UNDO of a mode switch rolls back the FX the span covered, and only those.** + The span carries `UNDO_STATE_FX` only once the drain has actually written FX under + it (`UndoSpan::widen`), so a switch whose parking never matured — a fast A→B→A + flip — still closes on `kViewUndoMask` alone and costs no FX serialization. What a + Ctrl-Z cannot restore is anything the reclaimer converges AFTER the span closed: a + transport deferral, and a drain no switch opened a span for, each mint their own + point. Whether that reads as correct in the DAW is `[verify — DAW]`, + `docs/VERIFICATION.md` §"Mode switching". +- **The `view_state` write is NOT inside `applyMode`'s block — but on a REAL switch + it is now inside the SPAN.** `persistViewState` (`design_view_actions.cpp`) runs + after `applyMode` has returned, and a real switch returns with the span open, so + the `SetProjExtState` — and the modal Save-As `persistViewState` can pop on an + unsaved project — land inside the block after all. That is a consequence of the + span, not a decision made here; it does not resolve the redo hypothesis below, + which turns on capture-time semantics the SDK does not document. `[verify — DAW]` + that a Save-As dialog opening under an open undo block is harmless. The rest of + this gotcha describes the reapply path, where nothing changed. The UNDO direction + still comes out right — the point closed over the ext state as it stood BEFORE the switch, which is what a Ctrl-Z should restore — but by position, not by design. The REDO direction is what this leaves open, and the cost is worse than the diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index 36ef36e..f5c60ee 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -38,6 +38,28 @@ FxReclaimQueue g_queue; // A release applied to the in-memory model and not yet written — see ReleaseFlush. ReleaseFlush g_releaseFlush; +// The one open undo block a mode switch and its parking share — see UndoSpan. +UndoSpan g_span; + +// The ONE open and the ONE close. Both idempotent, so every caller may say either +// unconditionally; the close reads the span's values before clearing it, because the +// clear has to precede the SDK call — nothing re-entered through Undo_EndBlock2 may +// find a span it could close a second time. +void openSpan(ReaProject* proj, const char* label, int mask) { + if (g_span.begin(proj, label, mask)) Undo_BeginBlock2(proj); +} + +void closeSpan() { + if (!g_span.open()) return; + ReaProject* const proj = g_span.project(); + const std::string label = g_span.label(); + const int mask = g_span.mask(); + g_span.end(); + // The span outlives its opener, so by here the project may be the one that just + // closed — its undo history went with it and there is no block left to end. + if (ValidatePtr2(nullptr, proj, "ReaProject*")) Undo_EndBlock2(proj, label.c_str(), mask); +} + // TrackFX_SetOffline is ASSUMED to pump the message loop, so a plugin load can re-enter // the timer and land back here mid-drain. Fail closed: a re-entered drain would derive a // second queue for the same project and race the outer one's writes track for track. @@ -132,16 +154,15 @@ struct ReclaimHost { if (fxIndex < 0 || fxIndex >= TrackFX_GetCount(tr)) return false; // chain shrank if (TrackFX_GetOffline(tr, fxIndex) == offline) return false; // already there - // DAW-VERIFIED: the same ten TrackFX_SetOffline calls mint ten undo points - // unblocked and ZERO inside this pair. One block per WRITE even though a step is - // now one whole track: the write itself is what pumps, so the block cannot avoid - // spanning a pump — it can only span as little of one as possible. Widening it to - // the track would multiply that exposure by the chain length while suppressing no - // extra undo point (the discard form mints none either way), and a user action - // landing inside a discarded block can lose its own Ctrl-Z. - Undo_BeginBlock2(project); + // Every write in the drain rides ONE span. Do not reintroduce a block per write: + // it minted one undo point per FX toggle, because the ("", 0) discard form + // coalesces rather than discards outside an action (see UndoSpan). The open is a + // no-op under a switch's span, which is why the FX bit is added separately — + // here, at the first real write, so a switch whose parking never matured closes + // on the flags-and-items mask it opened with and serializes no FX state. + openSpan(project, kReclaimUndoLabel, UNDO_STATE_FX); + g_span.widen(UNDO_STATE_FX); TrackFX_SetOffline(tr, fxIndex, offline); - Undo_EndBlock2(project, "", 0); return true; } @@ -188,6 +209,8 @@ void invalidateFxReclaim() { bool fxReclaimDrainInProgress() { return g_draining; } void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { + // Neither guard may close the span: both name a caller that is still on the stack and + // still owns it — the outer drain, or the applyMode that opened it. Both re-tick. if (g_draining) return; // The model is mid-rewrite and its records are being consumed out from under any // plan derived from it. The apply invalidates on its way out, so nothing is lost. @@ -200,6 +223,12 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { // that gate that poll. ReaProject* project = proj ? proj : EnumProjects(-1, nullptr, 0); if (!ValidatePtr2(nullptr, project, "ReaProject*")) project = nullptr; + + // A span belongs to the project it was opened on. Keyed on the SPAN's own project + // rather than the queue's pin, which moves independently: a tab switch, a closed + // project, or no active project at all ends it where it stands rather than carrying + // it into another project's history — and the no-project arm below returns. + if (g_span.open() && g_span.project() != project) closeSpan(); if (!g_queue.beginTick(project)) return; // DERIVED, never persisted: recomputed from view state every time the queue was @@ -213,9 +242,20 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { ReclaimHost host{project, &session}; const ReclaimRunResult run = g_queue.run(host); reportDrops(project, run.drops); + if (spanEndsAfter(run)) closeSpan(); if (!run.aborted && run.settled) g_queue.goIdle(); } +void fxReclaimBeginUndoSpan(ReaProject* proj) { + openSpan(proj, "", 0); +} + +void fxReclaimLabelUndoSpan(const char* label, int mask) { + g_span.relabel(label ? label : "", mask); +} + +void fxReclaimEndUndoSpan() { closeSpan(); } + void fxReclaimFlushRelease(ReaSamplerSession& session) { // GetCurrentProjectInLoadSave() names the project THIS hook is being asked to save // (reaper_plugin_functions.h:1582-1587) -- not merely the active tab, which a @@ -224,6 +264,9 @@ void fxReclaimFlushRelease(ReaSamplerSession& session) { // the project actually being written, or match the arm against the active tab while a // different project saved and write into that wrong project's ext state. ReaProject* saving = GetCurrentProjectInLoadSave(); + // BEFORE the arm test, and unconditionally: a save is a user act the span must not + // absorb, and the arm is not what decides whether one is open. + closeSpan(); if (!g_releaseFlush.pendingFor(saving)) return; // Promptless, unlike design_view_actions' persistViewState, which can open a modal // Save-As. [verify — DAW] whether this write reaches the block of the diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index b56fa28..e49d618 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "core/view/fx_state.h" @@ -155,6 +156,12 @@ struct ReclaimTickResult { int writes = 0; bool settled = false; + // The transport gate — and only it — held an otherwise-eligible item back this + // tick. Distinguished from the debounce because the two waits differ in KIND: a + // hold expires at kParkHoldMs, a deferral lasts as long as the playback pass does, + // and the undo span may only stay open across a wait it can bound (spanEndsAfter). + bool transportDeferred = false; + // Tracks whose park record the tick drove fully online, so the record's FX half is // spent. REPORTED, not applied: releasing is a model write and the model is exactly // what a re-entered load can replace mid-drain, so only the owner may commit it — @@ -178,8 +185,9 @@ struct ReclaimTickResult { // may still online while it rolls // bool liveChain(guid, std::vector&) FX identity per live slot; // false when the track is gone -// bool writeOffline(guid, fxIndex, offline) compare-before-write inside its -// own discarded undo block; +// bool writeOffline(guid, fxIndex, offline) compare-before-write, opening +// the drain's undo span if no +// switch already did (UndoSpan); // true iff it actually wrote // void releaseFx(guid) release the record's FX half and // arm the save-time flush that @@ -213,8 +221,10 @@ ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, H auto nextAllowed = [&](double nowMs) { const int i = nextEligible(items, clock, nowMs); if (i >= 0 && !offlineAllowed && - items[static_cast(i)].work.kind == view::FxWorkKind::Offline) + items[static_cast(i)].work.kind == view::FxWorkKind::Offline) { + result.transportDeferred = true; return -1; + } return i; }; @@ -274,6 +284,9 @@ struct ReclaimRunResult { int writes = 0; bool settled = false; + // Carried straight out of the tick — see ReclaimTickResult::transportDeferred. + bool transportDeferred = false; + // Something invalidated the queue while the drain was on the stack. The work and // the releases are DISCARDED, `settled` is never true, and the caller must not go // idle — the next tick re-derives against whatever changed. @@ -376,8 +389,18 @@ public: // back whether or not the drain's own work survives. clock_.swap(clock); - if (generation_ != entered) - return ReclaimRunResult{tick.writes, false, true, tick.drops}; + // Assembled field by field rather than positionally: the aggregate is read by + // the undo span's close decision now, and a silently mis-slotted bool there + // leaks an open block. + ReclaimRunResult run; + run.writes = tick.writes; + run.transportDeferred = tick.transportDeferred; + run.drops = tick.drops; + + if (generation_ != entered) { + run.aborted = true; + return run; + } items_.swap(work); // A release dropped by an abort is re-owed, not lost: the next derivation plans @@ -385,7 +408,8 @@ public: // release fires then. Applied in MEMORY only — the spend reaches disk on the // project's next save (ReleaseFlush), never on the drain's own initiative. for (const std::string& guid : tick.released) host.releaseFx(guid); - return ReclaimRunResult{tick.writes, tick.settled, false, tick.drops}; + run.settled = tick.settled; + return run; } private: @@ -433,6 +457,87 @@ private: ReaProject* project_ = nullptr; // COMPARE-ONLY, never dereferenced }; +// --- the undo span --------------------------------------------------------------- + +// The label a drain that no mode switch opened a span for closes under. Names the +// feature the user knows — Design View, and its CPU reclaim (the toggle's own tooltip +// wording) — rather than the loop behind it, and covers both directions: one act, +// converging the parked/returning chains to what the view state says. +inline constexpr const char* kReclaimUndoLabel = "ReaSampler: reclaim Design View FX"; + +// One open REAPER undo block, held across the timer callbacks the drain runs on. +// +// DAW-measured this phase, and the whole reason this type exists: block WIDTH is the +// entire effect. From a timer, ten TrackFX_SetOffline calls mint ten undo points +// unblocked, ten with a block PER WRITE, and ONE under a single block spanning all +// ten — the ("", 0) discard form does not discard outside an action, it coalesces. A +// block opened on one timer callback and closed on a LATER one holds. +// +// Pure: the Undo_BeginBlock2/EndBlock2 calls are fx_reclaim.cpp's, which owns the ONE +// instance and routes every path through one open and one close. Both moves report +// whether they actually moved, so a caller may say either unconditionally and still +// make no second BeginBlock2 and no unbalanced EndBlock2. +class UndoSpan { +public: + bool open() const { return open_; } + ReaProject* project() const { return project_; } + const std::string& label() const { return label_; } + int mask() const { return mask_; } + + // True iff this call opened it. `label`/`mask` are adopted only then, so a drain + // writing under a switch's span cannot displace the switch's own label. + bool begin(ReaProject* proj, std::string label, int mask) { + if (open_) return false; + project_ = proj; + label_ = std::move(label); + mask_ = mask; + open_ = true; + return true; + } + + // A mode switch decides its label only after its writes (applyMintsUndoPoint). + void relabel(std::string label, int mask) { + if (!open_) return; + label_ = std::move(label); + mask_ = mask; + } + + // Add state the span did not originally promise to serialize — the FX bit, once + // this drain has actually written FX under a switch's flags-and-items mask. + void widen(int extraMask) { + if (open_) mask_ |= extraMask; + } + + // True iff this call closed it; the caller owes Undo_EndBlock2 with the values it + // read BEFORE calling, since this clears them. + bool end() { + if (!open_) return false; + project_ = nullptr; + label_.clear(); + mask_ = 0; + open_ = false; + return true; + } + +private: + ReaProject* project_ = nullptr; // passed back to Undo_EndBlock2, never dereferenced here + std::string label_; + int mask_ = 0; + bool open_ = false; +}; + +// Must the tick that produced `run` end the span? +// +// The span may stay open only across a wait whose length it can bound. Waiting out the +// 3s debounce qualifies and is the wait the whole design turns on — that dead time sits +// between the switch and its first FX write. A TRANSPORT deferral does not: it lasts as +// long as the user plays, and a block held open for a playback pass swallows every edit +// made during it. Ending there costs a second undo point for the parking that follows, +// which is the documented degradation, not a failure. +inline bool spanEndsAfter(const ReclaimRunResult& run) { + return run.settled || run.aborted || run.transportDeferred; +} + // --- shell entry points -------------------------------------------------------- // The ONE test the timer makes per tick, mirroring the realtime capture's single @@ -460,6 +565,24 @@ bool fxReclaimDrainInProgress(); // park record's FX half — see FxReclaimQueue::run. void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj); +// Opens the undo span a mode switch and its background FX parking share, so the two are +// ONE Ctrl-Z. Called by applyMode in place of its own Undo_BeginBlock2; a REAL switch +// then returns with the span still open and the reclaimer closes it once the parking has +// drained. No-op if a span is already open, so callers END first (see applyMode) rather +// than nesting. +void fxReclaimBeginUndoSpan(ReaProject* proj); + +// Sets what the eventual close names the point. Separate from the begin because the +// switch's own label is only decided after its writes (applyMintsUndoPoint). +void fxReclaimLabelUndoSpan(const char* label, int mask); + +// The ONE close, idempotent: every terminating path routes through it — the drain +// settling, aborting, or deferring on the transport; the project retargeting or closing; +// the next apply superseding it; a project save; extension unload. A leaked open block +// would swallow everything the user does afterwards, so nothing may return past a +// terminating condition without calling this. +void fxReclaimEndUndoSpan(); + // Writes an in-memory release into the SAVING project's view state (identified via // GetCurrentProjectInLoadSave(), not the active tab). Called from the project-save // hook and nowhere else: this is the ONE path that makes a spent record durable, and diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 685f48a..f33d5f6 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -2,7 +2,8 @@ // reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). // Tree arithmetic lives in view_tree (pure), one track's park/restore in view_park // and the lane writes in view_lanes; this file owns the plan iteration, parent -// visibility, and the one undo block they all ride. +// visibility, and opening the one undo span they all ride (fx_reclaim's UndoSpan, +// which a real switch hands to the reclaimer still open). #include "shell/view/view.h" @@ -23,8 +24,6 @@ #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetPlayStateEx -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #define REAPERAPI_WANT_TrackList_AdjustWindows #define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_UpdateTimeline @@ -117,9 +116,13 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro bool laneModeChanged = false; bool wrote = false; // anything at all this apply changed — see applyMintsUndoPoint - std::vector exposedNames; // named while handles are fresh, reported after the block + std::vector exposedNames; // named while handles are fresh, reported after the writes - Undo_BeginBlock2(project); + // A prior switch's span, if the reclaimer has not closed it yet: this apply + // supersedes it, so that parking lands as its own point rather than nesting under + // ours. Both calls are idempotent — the end reaches nothing when none is open. + fxReclaimEndUndoSpan(); + fxReclaimBeginUndoSpan(project); { UiRefreshHold uiHold; // every write below lands with the TCP/MCP frozen @@ -195,14 +198,20 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro if (laneModeChanged) UpdateTimeline(); const bool mint = applyMintsUndoPoint(realSwitch, wrote); - Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? kViewUndoMask : 0); + fxReclaimLabelUndoSpan(mint ? undoLabel.c_str() : "", mint ? kViewUndoMask : 0); - // After the undo block: an EXPOSE report is a console message, not a project - // write, and must not join what a Ctrl-Z rolls back. + // A REAL switch returns with the span STILL OPEN: the reclaimer closes it once the + // parking this apply is about to invalidate has drained, so the switch and its FX + // writes are ONE Ctrl-Z. Everything else closes here as it always did — a reapply is + // not a switch, and the load tick must not leave a block open across a project open. + if (!realSwitch) fxReclaimEndUndoSpan(); + + // Neither of the two below is a project write — a console message and an in-memory + // fingerprint — so whether the span is still open over them is immaterial. reportExposedTracks(project, exposedNames); // The tool's own account of what it just left driven — the only one that survives - // an undo rolling the model back underneath it. + // an undo rolling the model back underneath it. In memory, never on disk. noteAppliedState(project, model); return true; diff --git a/src/shell/view/view.h b/src/shell/view/view.h index d91e5c4..643f7ba 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -32,9 +32,6 @@ bool transportBlocksModeSwitch(ReaProject* proj); // // A real switch mints regardless, fail-safe: it is an explicitly fired action and has // to stay undoable even in the degenerate case where the plan found nothing to write. -// Note what applyMode CANNOT see when it decides — the `view_state` ext-state write -// lands in persistViewState, after this block has closed (src/shell/view/CLAUDE.md's -// Gotchas). inline bool applyMintsUndoPoint(bool realSwitch, bool wroteAnything) { return realSwitch || wroteAnything; } @@ -54,6 +51,10 @@ bool modeApplyInProgress(); // path: TrackFX_SetOffline was 1897ms of a measured 1942ms switch, against 0.02ms for // all the flag work below. Do not reintroduce a synchronous FX write. // +// A REAL switch therefore RETURNS WITH ITS UNDO BLOCK STILL OPEN (fx_reclaim's +// UndoSpan): the reclaimer closes it once the parking has drained, which is what keeps +// the switch and its FX writes ONE point rather than two. A reapply closes its own. +// // Returns false (no mutation) if `targetModeId` isn't registered, if this is a real // switch (target != active) while the transport is running, if it is re-entered // (modeApplyInProgress), or if the project does not validate. A reapply (target == diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp index f28a070..77c3551 100644 --- a/tests/test_fx_reclaim.cpp +++ b/tests/test_fx_reclaim.cpp @@ -6,7 +6,9 @@ // the user's save rather than on the drain, and — via a host that invalidates from inside // a write — that a re-entered invalidation reaches nothing the drain is using. The queue // owns the project pin and the timer's wakeup for the same reason: a compare-only pointer -// makes the tab switch assertable. +// makes the tab switch assertable. The undo span is here on the same terms: its state +// machine is pure, so open-once/close-once and the wait it may or may not outlast are +// assertable; only the Undo_BeginBlock2/EndBlock2 calls themselves need a DAW. #include "../src/shell/view/fx_reclaim.h" @@ -59,6 +61,12 @@ struct FakeHost { bool rolling = false; // the transport gate's input + // Stands in for the shell's one span instance: fx_reclaim.cpp opens it on the first + // real write of a drain, exactly here, and widens it to carry the FX bit. Only the + // Undo_BeginBlock2/EndBlock2 calls themselves are out of reach without a DAW. + UndoSpan* span = nullptr; + int spanOpens = 0; + double nowMs() const { return clock; } bool transportRolling() const { return rolling; } @@ -76,6 +84,10 @@ struct FakeHost { if (fxIndex < 0 || fxIndex >= static_cast(it->second.size())) return false; if (it->second[static_cast(fxIndex)].offline == offline) return false; + if (span) { + if (span->begin(project, kReclaimUndoLabel, 2)) ++spanOpens; + span->widen(2); + } it->second[static_cast(fxIndex)].offline = offline; clock += stepCostMs; writeLog.push_back(guid + ":" + std::to_string(fxIndex) + (offline ? "=off" : "=on")); @@ -1011,6 +1023,180 @@ static void testAGatedTickStillGoesIdleWithNothingLeftToConverge() { CHECK(q.run(host).settled); } +// -- 10. the undo span ---------------------------------------------------------------- + +static void testTheSpanOpensOnceAndClosesOnceHoweverManyTimesItIsAsked() { + // The whole safety of the design is in these two answers: a caller may say begin or + // end unconditionally and still make no second Undo_BeginBlock2 and no unbalanced + // Undo_EndBlock2. A leaked open block swallows everything the user does afterwards. + UndoSpan span; + CHECK(!span.open()); + CHECK(!span.end()); // nothing open — the caller owes REAPER nothing + + CHECK(span.begin(kProjA, "ReaSampler: activate Design view", 9)); + CHECK(span.open()); + CHECK(span.project() == kProjA); + CHECK(span.label() == "ReaSampler: activate Design view"); + CHECK(span.mask() == 9); + + // The drain's own begin, arriving under a switch's span: it must adopt nothing — + // neither the other project, nor a label that would rename the user's switch. + CHECK(!span.begin(kProjB, kReclaimUndoLabel, 2)); + CHECK(span.project() == kProjA); + CHECK(span.label() == "ReaSampler: activate Design view"); + CHECK(span.mask() == 9); + + span.widen(2); // ...but the FX bit it DID write under still has to reach the point + CHECK(span.mask() == 11); + + span.relabel("ReaSampler: activate Arrange view", 1); + CHECK(span.label() == "ReaSampler: activate Arrange view"); + CHECK(span.mask() == 1); + + CHECK(span.end()); + CHECK(!span.open()); + CHECK(!span.end()); // the second close reaches nothing + CHECK(span.project() == nullptr); + + // And nothing a closed span carries can be revived out from under the next opener. + span.widen(4); + span.relabel("stale", 8); + CHECK(span.mask() == 0); + CHECK(span.label().empty()); +} + +static void testTheSpanOutlastsADebounceWaitButNeverATransportDeferral() { + // The one policy call: the span may stay open across a wait it can BOUND. The 3s + // debounce is the dead time between the switch and its first FX write, and spanning + // it is the whole point. A transport deferral lasts as long as the user plays. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + const ReclaimRunResult holding = q.run(host); // the hold starts at t=0 + CHECK(!holding.settled); + CHECK(!holding.transportDeferred); + CHECK(!spanEndsAfter(holding)); // bounded by kParkHoldMs — the span holds + + host.clock += kParkHoldMs; + host.rolling = true; + const ReclaimRunResult gated = q.run(host); + CHECK(gated.writes == 0); + CHECK(!gated.settled); + CHECK(gated.transportDeferred); + CHECK(spanEndsAfter(gated)); // unbounded — the switch's point closes without it + + host.rolling = false; + const ReclaimRunResult done = q.run(host); + CHECK(done.writes == 1); + CHECK(done.settled); + CHECK(!done.transportDeferred); + CHECK(spanEndsAfter(done)); +} + +static void testAnAbortEndsTheSpanSoNoLaterSwitchNestsUnderIt() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + q.run(host); + host.clock += kParkHoldMs; + + host.reentrant = &q; + host.invalidateAfterWrite = 1; + const ReclaimRunResult aborted = q.run(host); + CHECK(aborted.aborted); + CHECK(!aborted.settled); + CHECK(spanEndsAfter(aborted)); // the model moved; this span's work is superseded +} + +static void testOneSwitchAndItsMultiTickParkingAreOneSpan() { + // Three tracks at a cost that admits one per tick, so the parking really does span + // several timer callbacks — the case a per-write block turned into one undo point per + // FX toggle. The span must open exactly once and close exactly once, on the tick that + // settles, under the SWITCH's label rather than the reclaimer's. + ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}"}}, + {"{T2}", {"{FX-B}"}}, + {"{T3}", {"{FX-C}"}}}); + FakeHost host; + host.chains["{T1}"] = onlineChain({"{FX-A}"}); + host.chains["{T2}"] = onlineChain({"{FX-B}"}); + host.chains["{T3}"] = onlineChain({"{FX-C}"}); + host.stepCostMs = 50.0; // dearer than kOfflineBudgetMs ⇒ one track per tick + + // What applyMode leaves behind on a real switch: an open span, its label set, and no + // close — the reclaimer's to finish. + UndoSpan span; + CHECK(span.begin(kProjA, "", 0)); + span.relabel("ReaSampler: activate Design view", 9); + host.span = &span; + + int closes = 0; + FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}", "{T3}"}); + auto tick = [&]() { + const ReclaimRunResult r = q.run(host); + if (spanEndsAfter(r) && span.end()) ++closes; + return r; + }; + + CHECK(!tick().settled); // hold starts + host.clock += kParkHoldMs; + CHECK(!tick().settled); // {T1} + CHECK(!tick().settled); // {T2} + CHECK(tick().settled); // {T3}, and the queue is exhausted in the same pass + + CHECK(host.writeLog.size() == 3); + CHECK(host.spanOpens == 0); // the switch's span was already open — no second begin + CHECK(closes == 1); // ...and exactly one close, on the settling tick + CHECK(!span.open()); + + // Extra ticks after the settle must not produce a second close. + tick(); + CHECK(closes == 1); +} + +static void testADrainNoSwitchOpenedMintsOnePointOfItsOwn() { + // The non-switch origins — a membership edit's reapply, the load tick, a drain + // resuming after the transport stopped. Nobody opened a span, so the drain opens its + // own: one point for the whole convergence, never one per FX toggle. + ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}}, + {"{T2}", {"{FX-C}"}}}); + FakeHost host; + host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"}); + host.chains["{T2}"] = onlineChain({"{FX-C}"}); + host.stepCostMs = 50.0; + + UndoSpan span; + host.span = &span; + + int closes = 0; + FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}"}); + auto tick = [&]() { + const ReclaimRunResult r = q.run(host); + if (spanEndsAfter(r) && span.end()) ++closes; + return r; + }; + + CHECK(!tick().settled); // hold starts; nothing written, so nothing opened yet + CHECK(!span.open()); + CHECK(host.spanOpens == 0); + + host.clock += kParkHoldMs; + CHECK(!tick().settled); // {T1}'s chain: the first write opens the span + CHECK(host.spanOpens == 1); + CHECK(span.open()); + CHECK(span.label() == kReclaimUndoLabel); + CHECK(span.project() == kProjA); + + CHECK(tick().settled); // {T2}, still under the SAME span + CHECK(host.spanOpens == 1); + CHECK(host.writeLog.size() == 3); + CHECK(closes == 1); + CHECK(!span.open()); +} + int main() { testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); @@ -1048,6 +1234,12 @@ int main() { testAHoldDropsWhenItsTrackStopsOwingOfflineWorkWhileRolling(); testAGatedTickStillGoesIdleWithNothingLeftToConverge(); + testTheSpanOpensOnceAndClosesOnceHoweverManyTimesItIsAsked(); + testTheSpanOutlastsADebounceWaitButNeverATransportDeferral(); + testAnAbortEndsTheSpanSoNoLaterSwitchNestsUnderIt(); + testOneSwitchAndItsMultiTickParkingAreOneSpan(); + testADrainNoSwitchOpenedMintsOnePointOfItsOwn(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; } From 9b6f725747edca2c38560600e5f7ce5e591b281e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 13:45:58 -0400 Subject: [PATCH 02/14] Close the FX reclaimer's undo span at the state-load hook, and expose the drain's direction A Ctrl-Z mid-span used to close the pre-undo block over post-undo state; it now terminates at BeginLoadProjectState and stops the drain where it stands. fxReclaimActivity reports Online/Offline/Idle; fxReclaimPending is unchanged. --- src/app/main.cpp | 8 ++ src/shell/view/CLAUDE.md | 32 ++++-- src/shell/view/fx_reclaim.cpp | 48 +++++++-- src/shell/view/fx_reclaim.h | 124 +++++++++++++++++++---- tests/test_fx_reclaim.cpp | 179 ++++++++++++++++++++++++++++++++++ 5 files changed, 357 insertions(+), 34 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 2d7e60e..567c02c 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -247,7 +247,15 @@ static void OnTimer() static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) { if (isUndo) + { + // BEFORE the reload request, and the whole reason this is a hook rather than + // something the reload's own reapply does on arrival: the reclaimer's undo block + // is open for at least kParkHoldMs after every real mode switch, and a switch is + // exactly what this undo is rolling back. Closing it here is the last moment it + // still closes over the state it was opened on. + reasampler::fxReclaimAbandonForStateLoad(); g_session.requestReload(); + } } // Intentional no-op: ReaSampler stores state via project EXT STATE, not this diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 571d5d3..c6bb253 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -84,12 +84,25 @@ decide membership or mode rules. block swallows every edit the user makes afterwards, indefinitely, so every terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the drain settling, aborting on a generation bump, or deferring on the transport; the - project retargeting or closing; the next apply superseding it; a project save; - extension unload. The span may stay open only across a wait it can BOUND — the 3s - debounce, which is the dead time between the switch and its first FX write, and - the only reason the span exists. A TRANSPORT deferral is not bounded (it lasts as - long as the playback pass), so it ends the span and the parking that follows mints - its own point (`spanEndsAfter`). + project retargeting or closing; the next apply superseding it; a USER-DRIVEN STATE + LOAD; a project save; extension unload. The span may stay open only across a wait it + can BOUND — the 3s debounce, which is the dead time between the switch and its first + FX write, and the only reason the span exists. A TRANSPORT deferral is not bounded + (it lasts as long as the playback pass), so it ends the span and the parking that + follows mints its own point (`spanEndsAfter`). +- **A Ctrl-Z terminates the span itself, at the hook, before REAPER restores + anything.** `fxReclaimAbandonForStateLoad` runs from `BeginLoadProjectState` + (`main.cpp`) — the only point at which the block still closes over the state it was + OPENED on. The span is open for at least `kParkHoldMs` after every real switch and a + switch is exactly what a user undoes, so letting the close happen where it used to — + as a side effect of the reapply the reloaded model triggers, a full timer tick later + — closed the pre-undo block over POST-undo state, minting a spurious point and + destroying the redo branch. The same call stops any drain on the stack where it + stands (`FxReclaimQueue::abandon`, refused at both host entry points), which is + stronger than an invalidation: an invalidation still finishes the track it is on, + because a step is atomic. Daniel's ruling, and the accepted cost: undoing a switch + mid-drain leaves a PARTIAL park — some FX offlined, some not — covered by the one + point the terminator closed. - **A reapply that wrote nothing leaves no undo point** — and a reapply never holds the span open, so a project open, a membership edit and `render_in_place`'s inner reapply all keep the block shape they had. The load-tick reapply @@ -148,7 +161,7 @@ applies the resulting lane state to live tracks. - `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). - `view_tracks` — the scaffolding every write pass sets up: `TrackHandles`/`TrackByGuid`, `readFolderEntries`/`indexHandles`, and `UiRefreshHold` (the `PreventUIRefresh` RAII pair every write phase runs under). - `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`. -- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. +- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate; `fxReclaimAbandonForStateLoad` is the undo/redo terminator, which also stops the drain). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. `fxReclaimActivity` reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected `fxReclaimPending` stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible). ## Gotchas @@ -182,8 +195,9 @@ applies the resulting lane state to live tracks. flip — still closes on `kViewUndoMask` alone and costs no FX serialization. What a Ctrl-Z cannot restore is anything the reclaimer converges AFTER the span closed: a transport deferral, and a drain no switch opened a span for, each mint their own - point. Whether that reads as correct in the DAW is `[verify — DAW]`, - `docs/VERIFICATION.md` §"Mode switching". + point — and, since the terminator above, whatever the drain had NOT yet written when + the undo arrived, because the span closes at that instant. Whether that reads as + correct in the DAW is `[verify — DAW]`, `docs/VERIFICATION.md` §"Mode switching". - **The `view_state` write is NOT inside `applyMode`'s block — but on a REAL switch it is now inside the SPAN.** `persistViewState` (`design_view_actions.cpp`) runs after `applyMode` has returned, and a real switch returns with the span open, so diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index f5c60ee..7d1c4ce 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -60,6 +60,15 @@ void closeSpan() { if (ValidatePtr2(nullptr, proj, "ReaProject*")) Undo_EndBlock2(proj, label.c_str(), mask); } +// Monotonic and sub-millisecond: the drain's budget discriminates at 30ms, which +// GetTickCount's ~16ms granularity cannot do. No wraparound to guard against, so +// util::elapsedAtLeast (whose whole reason for existing is that wrap) does not apply. +double monotonicMs() { + return std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + // TrackFX_SetOffline is ASSUMED to pump the message loop, so a plugin load can re-enter // the timer and land back here mid-drain. Fail closed: a re-entered drain would derive a // second queue for the same project and race the outer one's writes track for track. @@ -122,21 +131,18 @@ struct ReclaimHost { return cachedTrack_; } - // Monotonic and sub-millisecond: the budget below discriminates at 30ms, which - // GetTickCount's ~16ms granularity cannot do. No wraparound to guard against, so - // util::elapsedAtLeast (whose whole reason for existing is that wrap) does not apply. - double nowMs() const { - return std::chrono::duration( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - } + double nowMs() const { return monotonicMs(); } // The SAME predicate applyMode refuses a real mode switch on, deliberately shared // rather than re-read here: "the transport is rolling" is one fact with one home // (view.h). drainTick narrows what it gates with this answer to offline work only. bool transportRolling() const { return transportBlocksModeSwitch(project); } + // Refusing here ends the drain's REMAINING tracks after an abandon: drainTick treats a + // false as "this track is gone", so the rest of the queue costs one bool apiece and + // reaches no REAPER call at all. bool liveChain(const std::string& guid, std::vector& out) { + if (g_queue.abandoned()) return false; MediaTrack* tr = resolveForStep(guid); if (!tr) return false; const int count = TrackFX_GetCount(tr); @@ -148,7 +154,11 @@ struct ReclaimHost { return true; } + // ...and here for the track being written RIGHT NOW, whose own pump is how the state + // load arrived. FIRST, before the openSpan below: a write past the terminator would + // both land in the project the load is replacing and reopen the span it just closed. bool writeOffline(const std::string& guid, int fxIndex, bool offline) { + if (g_queue.abandoned()) return false; MediaTrack* tr = resolveForStep(guid); if (!tr) return false; if (fxIndex < 0 || fxIndex >= TrackFX_GetCount(tr)) return false; // chain shrank @@ -199,6 +209,20 @@ void reportDrops(ReaProject* proj, const std::vector& drops) { bool fxReclaimPending() { return g_queue.pending(); } +FxReclaimActivity fxReclaimActivity() { + // The QUEUE's own pin, never the active tab: the direction is a fact about the project + // the drain is converging, and reading another tab's transport into it would gate the + // answer on a playhead that has nothing to do with this work. No pin means idle — and + // costs the caller no REAPER call at all, which is what makes this safe to poll per + // frame. + ReaProject* const proj = g_queue.project(); + if (!proj) return FxReclaimActivity::Idle; + // The pin is only re-tested at beginTick; a poll landing between two ticks can find the + // project already gone, and GetPlayStateEx is about to be handed this pointer. + if (!ValidatePtr2(nullptr, proj, "ReaProject*")) return FxReclaimActivity::Idle; + return g_queue.activity(monotonicMs(), transportBlocksModeSwitch(proj)); +} + void invalidateFxReclaim() { // A generation bump and a flag, and deliberately nothing more. Discarding the queue // here is what let a re-entered apply free the element the outer drain was writing @@ -256,6 +280,14 @@ void fxReclaimLabelUndoSpan(const char* label, int mask) { void fxReclaimEndUndoSpan() { closeSpan(); } +void fxReclaimAbandonForStateLoad() { + // Close FIRST and unconditionally. The abandon below only stops future writes; the + // block is what a Ctrl-Z would otherwise close over the wrong state, and it is open + // whether or not a drain is on the stack at all. + closeSpan(); + g_queue.abandon(); +} + void fxReclaimFlushRelease(ReaSamplerSession& session) { // GetCurrentProjectInLoadSave() names the project THIS hook is being asked to save // (reaper_plugin_functions.h:1582-1587) -- not merely the active tab, which a diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index e49d618..9d402a1 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -122,6 +122,56 @@ inline int nextEligible(const std::vector& items, const ParkClock& return -1; } +// The item a step may move RIGHT NOW, under BOTH gates the loop applies: the debounce +// above, and the transport, which mirrors applyMode's refusal of a real mode switch +// (view.h, transportBlocksModeSwitch) narrowed to the OFFLINE direction only — +// onlining is the switch's own unfinished promise and the user is waiting on it, while +// offlining is opportunistic reclaim that can always wait for the transport to stop. +// -1 when nothing may move; `transportDeferred` is raised only when the transport, and +// not the debounce, is what held an otherwise-eligible item back. +// +// The ONE home of that pair: drainTick steps through it and nextActivity reports +// through it, so the direction an indicator paints can never disagree with the +// direction the next step actually takes. +inline int nextMovable(const std::vector& items, const ParkClock& clock, + double nowMs, bool transportRolling, bool& transportDeferred) { + const int i = nextEligible(items, clock, nowMs); + if (i < 0) return -1; + if (transportRolling && + items[static_cast(i)].work.kind == view::FxWorkKind::Offline) { + transportDeferred = true; + return -1; + } + return i; +} + +// Which way the drain is MOVING — the panel's indicator, and undirected `fxReclaimPending` +// is what it may not be driven off: for the whole ~3s after a switch the queue holds the +// park's offline work while the only thing eligible is the RETURNING chains' online work, +// so "pending" there names the opposite of what is happening. +// +// Idle means nothing may move on this tick, and deliberately does not distinguish why — +// an empty queue, an offline item inside its hold, and an offline item held by a rolling +// transport all read Idle, because none of them is work the user is currently waiting +// through. Online while the transport rolls is a real state and reads Online: that +// direction is not gated. +enum class FxReclaimActivity { + Idle, + Online, + Offline, +}; + +inline FxReclaimActivity nextActivity(const std::vector& items, + const ParkClock& clock, double nowMs, + bool transportRolling) { + bool deferred = false; + const int i = nextMovable(items, clock, nowMs, transportRolling, deferred); + if (i < 0) return FxReclaimActivity::Idle; + return items[static_cast(i)].work.kind == view::FxWorkKind::Online + ? FxReclaimActivity::Online + : FxReclaimActivity::Offline; +} + // --- the drain ---------------------------------------------------------------- // One ONLINE item's captured state the chain had no home for. An OFFLINE item can @@ -211,21 +261,13 @@ ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, H // its track genuinely stops owing offline work, matured or not. clock.observe(items, host.nowMs()); - // Mirrors applyMode's refusal (view.h, transportBlocksModeSwitch), narrowed to the - // OFFLINE direction only: onlining is the mode switch's own unfinished promise — the - // user is waiting on it — while offlining is opportunistic reclaim that can always - // wait for the transport to stop. Onlining while rolling still costs a plugin load - // mid-playback; that is an accepted trade for this gate's shape, not a hazard it - // introduces. - const bool offlineAllowed = !host.transportRolling(); + // Read once for the whole tick and applied through nextMovable, which owns the gate + // (and the reasoning for narrowing it to Offline). Onlining while rolling still costs + // a plugin load mid-playback; that is an accepted trade for this gate's shape, not a + // hazard it introduces. + const bool rolling = host.transportRolling(); auto nextAllowed = [&](double nowMs) { - const int i = nextEligible(items, clock, nowMs); - if (i >= 0 && !offlineAllowed && - items[static_cast(i)].work.kind == view::FxWorkKind::Offline) { - result.transportDeferred = true; - return -1; - } - return i; + return nextMovable(items, clock, nowMs, rolling, result.transportDeferred); }; const int first = nextAllowed(host.nowMs()); @@ -318,6 +360,21 @@ public: pending_ = true; } + // A project-state load the USER drove (undo/redo). The whole project is about to be + // replaced, so this is STRONGER than an invalidation: the queue's work is discarded as + // usual, AND any drain on the stack — reachable only through a write's own message + // pump — stops where it stands instead of finishing the track it is on. Accepted, and + // the reason a step's atomicity yields here: a park half-landed this way stays + // half-landed, covered by the one point the terminator closed. + void abandon() { + invalidate(); + abandoned_ = true; + } + + // Read by the shell's host on every step; false again from the next run(), because the + // stop belongs to the tick that was interrupted, not to the project that replaced it. + bool abandoned() const { return abandoned_; } + // The ONE test the timer makes per tick. False means settled: nothing to converge // until the next invalidation. bool pending() const { return pending_; } @@ -374,16 +431,29 @@ public: const std::vector& items() const { return items_; } + // Which way the drain is moving, for an indicator to paint — see nextActivity, which + // owns the rule. Const and cheap: the panel polls it per frame. + FxReclaimActivity activity(double nowMs, bool transportRolling) const { + // The queue's own storage is EMPTY for a drain's whole duration (run() moves it + // into locals), and a write pumps the message loop — so a repaint landing inside + // one would otherwise read the drain as idle at exactly the moment it is working. + if (draining_ != FxReclaimActivity::Idle) return draining_; + return nextActivity(items_, clock_, nowMs, transportRolling); + } + template ReclaimRunResult run(Host& host) { const unsigned long long entered = generation_; + abandoned_ = false; std::vector work; ParkClock clock; work.swap(items_); clock.swap(clock_); + draining_ = nextActivity(work, clock, host.nowMs(), host.transportRolling()); const ReclaimTickResult tick = drainTick(work, clock, host); + draining_ = FxReclaimActivity::Idle; // The hold is keyed on the track and outlives any re-derivation, so it comes // back whether or not the drain's own work survives. @@ -420,6 +490,8 @@ private: unsigned long long derivedAt_ = 0; bool derived_ = false; bool pending_ = false; + bool abandoned_ = false; + FxReclaimActivity draining_ = FxReclaimActivity::Idle; }; // --- the release's durable half -------------------------------------------------- @@ -544,6 +616,13 @@ inline bool spanEndsAfter(const ReclaimRunResult& run) { // pointer test. False means the queue is settled and there is nothing to converge. bool fxReclaimPending(); +// Which direction the drain is moving, answered against the project the queue was derived +// against rather than the active tab. `fxReclaimPending` above is deliberately left +// undirected and unchanged; this is the surface an indicator must be driven off instead, +// because "pending" is true throughout the debounce, when the only thing moving is the +// OPPOSITE direction. Idle whenever nothing may move — see FxReclaimActivity. +FxReclaimActivity fxReclaimActivity(); + // Raises the wakeup and marks the derived queue for re-derivation by the NEXT tick; // discards nothing here, because this is reachable from inside a drain. Called from // applyMode and nothing else @@ -578,11 +657,22 @@ void fxReclaimLabelUndoSpan(const char* label, int mask); // The ONE close, idempotent: every terminating path routes through it — the drain // settling, aborting, or deferring on the transport; the project retargeting or closing; -// the next apply superseding it; a project save; extension unload. A leaked open block -// would swallow everything the user does afterwards, so nothing may return past a -// terminating condition without calling this. +// the next apply superseding it; a user-driven state load; a project save; extension +// unload. A leaked open block would swallow everything the user does afterwards, so +// nothing may return past a terminating condition without calling this. void fxReclaimEndUndoSpan(); +// A project-state load the USER drove — Ctrl-Z or Ctrl-Shift-Z. Ends the span and stops +// any drain on the stack (FxReclaimQueue::abandon). +// +// Called from the projectconfig BeginLoadProjectState hook, which fires BEFORE REAPER +// restores anything: that is the ONLY point at which the block can still close over the +// state it was opened on. The span is open for at least kParkHoldMs after every real +// switch and a switch is exactly what a user undoes, so closing it as a side effect of +// the reapply the load triggers — several calls later, over the restored project — would +// mint the point over post-undo state and destroy the redo branch. +void fxReclaimAbandonForStateLoad(); + // Writes an in-memory release into the SAVING project's view state (identified via // GetCurrentProjectInLoadSave(), not the active tab). Called from the project-save // hook and nowhere else: this is the ONE path that makes a spent record durable, and diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp index 77c3551..6987ae1 100644 --- a/tests/test_fx_reclaim.cpp +++ b/tests/test_fx_reclaim.cpp @@ -59,6 +59,14 @@ struct FakeHost { int invalidateAfterWrite = -1; int itemsSeenMidDrain = -1; // queue size as the re-entrant caller finds it + // A Ctrl-Z arriving through the same pump: what fxReclaimAbandonForStateLoad does, + // fired on the Nth successful write. The direction probe rides the same hook because + // the property it checks is the same one — what a caller re-entering mid-drain sees. + int abandonAfterWrite = -1; + int probeActivityAfterWrite = -1; + int spanClosesMidDrain = 0; + FxReclaimActivity activityMidDrain = FxReclaimActivity::Idle; + bool rolling = false; // the transport gate's input // Stands in for the shell's one span instance: fx_reclaim.cpp opens it on the first @@ -70,7 +78,10 @@ struct FakeHost { double nowMs() const { return clock; } bool transportRolling() const { return rolling; } + // Both host entry points refuse once abandoned, exactly as fx_reclaim.cpp's do: this + // one ends every REMAINING track, writeOffline below ends the one being written. bool liveChain(const std::string& guid, std::vector& out) { + if (reentrant && reentrant->abandoned()) return false; auto it = chains.find(guid); if (it == chains.end()) return false; out.clear(); @@ -79,6 +90,7 @@ struct FakeHost { } bool writeOffline(const std::string& guid, int fxIndex, bool offline) { + if (reentrant && reentrant->abandoned()) return false; auto it = chains.find(guid); if (it == chains.end()) return false; if (fxIndex < 0 || fxIndex >= static_cast(it->second.size())) return false; @@ -97,6 +109,12 @@ struct FakeHost { reentrant->discard(); reentrant->invalidate(); } + if (reentrant && static_cast(writeLog.size()) == probeActivityAfterWrite) + activityMidDrain = reentrant->activity(clock, rolling); + if (reentrant && static_cast(writeLog.size()) == abandonAfterWrite) { + if (span && span->end()) ++spanClosesMidDrain; // closeSpan, then the stop + reentrant->abandon(); + } return true; } @@ -1197,6 +1215,160 @@ static void testADrainNoSwitchOpenedMintsOnePointOfItsOwn() { CHECK(!span.open()); } +// -- 11. the state-load terminator ------------------------------------------------------ + +static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack() { + // The contrast with testAReentrantInvalidationReachesNothingTheDrainIsUsing above, + // which finishes its track's remaining three writes: an ordinary invalidation only + // says the model moved, while a state load says the PROJECT is being replaced under + // us and the block covering these writes has already closed. So a step is atomic only + // up to the abandon — the half-parked chain that leaves is the accepted cost. + ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}}, + {"{T2}", {"{FX-C}"}}}); + FakeHost host; + host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"}); + host.chains["{T2}"] = onlineChain({"{FX-C}"}); + host.stepCostMs = 1.0; // cheap enough that only the abandon can stop the tick + + UndoSpan span; // what a real switch left open + CHECK(span.begin(kProjA, "ReaSampler: activate Design view", 9)); + host.span = &span; + + FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}"}); + q.run(host); // observes both parked at t=0 + host.clock += kParkHoldMs; + + host.reentrant = &q; + host.abandonAfterWrite = 1; + const ReclaimRunResult r = q.run(host); + + CHECK(host.writeLog == std::vector{"{T1}:0=off"}); + CHECK(host.offlineAt("{T1}", 0)); + CHECK(!host.offlineAt("{T1}", 1)); // the same track, mid-chain — the partial park + CHECK(!host.offlineAt("{T2}", 0)); // and every track after it, untouched + + CHECK(host.spanClosesMidDrain == 1); + CHECK(!span.open()); + CHECK(r.aborted); + CHECK(!r.settled); + CHECK(spanEndsAfter(r)); // the tick's own close still fires... + CHECK(!span.end()); // ...and finds nothing: the terminator got there first + + // The wakeup stands and the work is owed again: going idle here would strand the + // half-parked chain for the rest of the session. + CHECK(q.stale()); + CHECK(q.pending()); + + // ...and the stop belonged to that tick alone. The reloaded project's own drain writes. + host.abandonAfterWrite = -1; + q.adopt(derive(vm, {"{T1}", "{T2}"})); + CHECK(q.run(host).writes == 2); // {T1}'s remaining slot, and {T2}'s whole chain + CHECK(host.offlineAt("{T1}", 1)); + CHECK(host.offlineAt("{T2}", 0)); +} + +// -- 12. the direction the drain is moving ---------------------------------------------- + +static void testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold() { + // The panel bug this surface exists for: `pending` is true from the instant of the + // switch, but for the whole kParkHoldMs the only thing eligible is the RETURNING + // chains' online work — an indicator driven off the queue's mere contents would name + // parking while the drain is doing the opposite. + ViewModeModel vm; + vm.membership().tag("{Z-back}", kArrangeModeId); // active; owed an online + vm.membership().tag("{A-park}", kDesignModeId); // parked; owed an offline + vm.storeSnapshot("{Z-back}", recordWithFx({{"{FX-1}", 0}})); + vm.storeSnapshot("{A-park}", recordWithFx({{"{FX-2}", 0}})); + + FakeHost host; + host.chains["{Z-back}"] = offlineChain({"{FX-1}"}); + host.chains["{A-park}"] = onlineChain({"{FX-2}"}); + host.stepCostMs = 1.0; + + FxReclaimQueue q = queueOf(vm, {"{Z-back}", "{A-park}"}); + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Online); + + CHECK(q.run(host).writes == 1); // the online item only — the offline is still held + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); + + host.clock += kParkHoldMs; + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Offline); + CHECK(q.run(host).settled); + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); +} + +static void testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + // Idle, not Offline: nothing may move, and the wait is the reason the span is allowed + // to stay open across it. Reporting Offline here is what would light the indicator for + // 3s before a single plugin moves. + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); + CHECK(!q.run(host).settled); // the hold's origin is set by this tick's observe + CHECK(host.writeLog.empty()); + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); + + host.clock += kParkHoldMs; + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Offline); + CHECK(q.run(host).writes == 1); +} + +static void testARollingTransportHidesOfflineWorkFromTheDirectionButNeverOnline() { + ViewModeModel vm; + vm.membership().tag("{Z-back}", kArrangeModeId); + vm.membership().tag("{A-park}", kDesignModeId); + vm.storeSnapshot("{Z-back}", recordWithFx({{"{FX-1}", 0}})); + vm.storeSnapshot("{A-park}", recordWithFx({{"{FX-2}", 0}})); + + FakeHost host; + host.chains["{Z-back}"] = offlineChain({"{FX-1}"}); + host.chains["{A-park}"] = onlineChain({"{FX-2}"}); + host.stepCostMs = 1.0; + host.rolling = true; + + FxReclaimQueue q = queueOf(vm, {"{Z-back}", "{A-park}"}); + // Online is not transport-gated, so it both drains and reads through a rolling + // transport — the second half of the panel bug: this work had no indicator at all. + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Online); + CHECK(q.run(host).writes == 1); + host.clock += kParkHoldMs; + + // The offline half is eligible now and still may not move, so it reads Idle rather + // than naming a direction nothing is travelling in. + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); + CHECK(q.run(host).transportDeferred); + CHECK(host.writeLog.size() == 1); + + host.rolling = false; + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Offline); + CHECK(q.run(host).writes == 1); +} + +static void testTheDirectionHoldsThroughTheDrainsOwnMessagePump() { + // A write pumps, and a repaint landing in there polls this. The queue object holds + // NOTHING for the drain's whole duration (itemsSeenMidDrain == 0, asserted above), so + // without the latch the indicator would read Idle at exactly the moment the plugin + // loads the user is waiting through are happening. + ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}}}); + FakeHost host; + host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"}); + host.stepCostMs = 1.0; + + FxReclaimQueue q = queueOf(vm, {"{T1}"}); + q.run(host); + host.clock += kParkHoldMs; + + host.reentrant = &q; + host.probeActivityAfterWrite = 1; + CHECK(q.run(host).settled); + + CHECK(host.activityMidDrain == FxReclaimActivity::Offline); + CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); // latch released +} + int main() { testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); @@ -1240,6 +1412,13 @@ int main() { testOneSwitchAndItsMultiTickParkingAreOneSpan(); testADrainNoSwitchOpenedMintsOnePointOfItsOwn(); + testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(); + + testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold(); + testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures(); + testARollingTransportHidesOfflineWorkFromTheDirectionButNeverOnline(); + testTheDirectionHoldsThroughTheDrainsOwnMessagePump(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; } From 39b995e824d0ba50590d6a03bbd7241e868fa58f Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 13:57:06 -0400 Subject: [PATCH 03/14] Terminate the FX reclaimer's undo span on EVERY project-state load, not just undo BeginLoadProjectState now routes unbranched through fxReclaimAbandonForStateLoad; only the session's deferred reload stays undo-only. --- src/app/main.cpp | 20 +++++++++----------- src/shell/view/CLAUDE.md | 34 ++++++++++++++++++++++------------ src/shell/view/fx_reclaim.h | 28 +++++++++++++++++----------- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 567c02c..0ae42b1 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -241,21 +241,19 @@ static void OnTimer() // TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading // GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot // reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has -// finished restoring the block. A normal project open also fires this -// (isUndo=false); ignored here so a normal open flows solely through the timer's -// identity-transition Load path (no double load). +// finished restoring the block. Only the RELOAD REQUEST is undo-only: a +// normal open's reload belongs to the timer's identity-transition Load path, whose +// forked-sibling re-GUID branch requesting a reload here would bypass. static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) { + // UNBRANCHED, and the reason the terminator is a hook at all: this is REAPER's one + // entry for a state load — "project load/undo", plus a new project + // (reaper_plugin.h:1203-1205) — and the last moment before the restore. Why that + // matters, and why isUndo does not narrow it: fxReclaimAbandonForStateLoad. + reasampler::fxReclaimAbandonForStateLoad(); + if (isUndo) - { - // BEFORE the reload request, and the whole reason this is a hook rather than - // something the reload's own reapply does on arrival: the reclaimer's undo block - // is open for at least kParkHoldMs after every real mode switch, and a switch is - // exactly what this undo is rolling back. Closing it here is the last moment it - // still closes over the state it was opened on. - reasampler::fxReclaimAbandonForStateLoad(); g_session.requestReload(); - } } // Intentional no-op: ReaSampler stores state via project EXT STATE, not this diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index c6bb253..5135ef8 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -84,25 +84,35 @@ decide membership or mode rules. block swallows every edit the user makes afterwards, indefinitely, so every terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the drain settling, aborting on a generation bump, or deferring on the transport; the - project retargeting or closing; the next apply superseding it; a USER-DRIVEN STATE + project retargeting or closing; the next apply superseding it; ANY PROJECT-STATE LOAD; a project save; extension unload. The span may stay open only across a wait it can BOUND — the 3s debounce, which is the dead time between the switch and its first FX write, and the only reason the span exists. A TRANSPORT deferral is not bounded (it lasts as long as the playback pass), so it ends the span and the parking that follows mints its own point (`spanEndsAfter`). -- **A Ctrl-Z terminates the span itself, at the hook, before REAPER restores - anything.** `fxReclaimAbandonForStateLoad` runs from `BeginLoadProjectState` +- **EVERY project-state load terminates the span itself, at the hook, before REAPER + restores anything — `isUndo` does not narrow it.** + `fxReclaimAbandonForStateLoad` runs UNBRANCHED from `BeginLoadProjectState` (`main.cpp`) — the only point at which the block still closes over the state it was - OPENED on. The span is open for at least `kParkHoldMs` after every real switch and a - switch is exactly what a user undoes, so letting the close happen where it used to — - as a side effect of the reapply the reloaded model triggers, a full timer tick later - — closed the pre-undo block over POST-undo state, minting a spurious point and - destroying the redo branch. The same call stops any drain on the stack where it + OPENED on, and the only entry REAPER gives for a state load at all, so undo, redo, a + project open, a `File > Revert` and a new project (`reaper_plugin.h:1203-1205`) all + reach the one terminator. The span is open for at least `kParkHoldMs` after every + real switch and a switch is exactly what a user undoes, so letting the close happen + where it used to — as a side effect of the reapply the reloaded model triggers, a + full timer tick later — closed the pre-undo block over POST-undo state, minting a + spurious point and destroying the redo branch. A NON-undo load left to the next + tick's project-retarget close is the identical defect one branch away: the same block + closes over the same post-load state, a tick late. `isUndo` decides one thing only, + in `main.cpp`: whether the SESSION owes a deferred ext-state reload (a normal open's + belongs to the identity poll's Load branch, whose forked-sibling re-GUID a reload + request here would bypass). The same call stops any drain on the stack where it stands (`FxReclaimQueue::abandon`, refused at both host entry points), which is stronger than an invalidation: an invalidation still finishes the track it is on, - because a step is atomic. Daniel's ruling, and the accepted cost: undoing a switch - mid-drain leaves a PARTIAL park — some FX offlined, some not — covered by the one - point the terminator closed. + because a step is atomic. A load that keeps the SAME `ReaProject` — a revert — is + what makes that stop load-bearing rather than tidy, for the reason stated at + `abandon`. Daniel's ruling, and the accepted cost: undoing a switch mid-drain leaves + a PARTIAL park — some FX offlined, some not — covered by the one point the + terminator closed. - **A reapply that wrote nothing leaves no undo point** — and a reapply never holds the span open, so a project open, a membership edit and `render_in_place`'s inner reapply all keep the block shape they had. The load-tick reapply @@ -161,7 +171,7 @@ applies the resulting lane state to live tracks. - `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). - `view_tracks` — the scaffolding every write pass sets up: `TrackHandles`/`TrackByGuid`, `readFolderEntries`/`indexHandles`, and `UiRefreshHold` (the `PreventUIRefresh` RAII pair every write phase runs under). - `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`. -- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate; `fxReclaimAbandonForStateLoad` is the undo/redo terminator, which also stops the drain). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. `fxReclaimActivity` reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected `fxReclaimPending` stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible). +- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate; `fxReclaimAbandonForStateLoad` is the state-load terminator, which also stops the drain). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. `fxReclaimActivity` reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected `fxReclaimPending` stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible). ## Gotchas diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index 9d402a1..7162df7 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -360,12 +360,15 @@ public: pending_ = true; } - // A project-state load the USER drove (undo/redo). The whole project is about to be - // replaced, so this is STRONGER than an invalidation: the queue's work is discarded as - // usual, AND any drain on the stack — reachable only through a write's own message - // pump — stops where it stands instead of finishing the track it is on. Accepted, and - // the reason a step's atomicity yields here: a park half-landed this way stays - // half-landed, covered by the one point the terminator closed. + // A project-state load — undo, redo, an open, a revert, a new project. The whole + // project is about to be replaced, so this is STRONGER than an invalidation: the + // queue's work is discarded as usual, AND any drain on the stack — reachable only + // through a write's own message pump — stops where it stands instead of finishing the + // track it is on. Accepted, and the reason a step's atomicity yields here: a park + // half-landed this way stays half-landed, covered by the one point the terminator + // closed. A load that keeps the SAME ReaProject (a revert) is what makes the stop + // load-bearing rather than tidy: the drain's pin and cached track handle both still + // validate afterwards, so nothing else would keep it out of the reloaded project. void abandon() { invalidate(); abandoned_ = true; @@ -657,20 +660,23 @@ void fxReclaimLabelUndoSpan(const char* label, int mask); // The ONE close, idempotent: every terminating path routes through it — the drain // settling, aborting, or deferring on the transport; the project retargeting or closing; -// the next apply superseding it; a user-driven state load; a project save; extension +// the next apply superseding it; ANY project-state load; a project save; extension // unload. A leaked open block would swallow everything the user does afterwards, so // nothing may return past a terminating condition without calling this. void fxReclaimEndUndoSpan(); -// A project-state load the USER drove — Ctrl-Z or Ctrl-Shift-Z. Ends the span and stops -// any drain on the stack (FxReclaimQueue::abandon). +// EVERY project-state load, undo-driven or not — Ctrl-Z, Ctrl-Shift-Z, a project open, a +// File > Revert, a new project. Ends the span and stops any drain on the stack +// (FxReclaimQueue::abandon). // // Called from the projectconfig BeginLoadProjectState hook, which fires BEFORE REAPER // restores anything: that is the ONLY point at which the block can still close over the // state it was opened on. The span is open for at least kParkHoldMs after every real // switch and a switch is exactly what a user undoes, so closing it as a side effect of -// the reapply the load triggers — several calls later, over the restored project — would -// mint the point over post-undo state and destroy the redo branch. +// the load — several calls later, over the restored project — would mint the point over +// post-load state and, for an undo, destroy the redo branch. That defect does not turn on +// isUndo: a non-undo load left to the next tick's project-retarget close lands the same +// point over the same wrong state, one hook branch away, so this hook does not branch. void fxReclaimAbandonForStateLoad(); // Writes an in-memory release into the SAVING project's view state (identified via From 67d9ec3da902cdb8df46e6973eedd614dda50d80 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 14:23:56 -0400 Subject: [PATCH 04/14] Put the FX reclaimer to sleep on a project-state load instead of waking it abandon() clears the wakeup where invalidate() raises it, so File > Revert cannot converge FX or spend park records against a model nothing re-read. --- src/shell/view/CLAUDE.md | 16 ++++++++ src/shell/view/fx_reclaim.cpp | 6 +-- src/shell/view/fx_reclaim.h | 22 +++++++++-- tests/test_fx_reclaim.cpp | 71 +++++++++++++++++++++++++++++++++-- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 5135ef8..08b40e4 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -113,6 +113,22 @@ decide membership or mode rules. `abandon`. Daniel's ruling, and the accepted cost: undoing a switch mid-drain leaves a PARTIAL park — some FX offlined, some not — covered by the one point the terminator closed. +- **A state load puts the reclaimer to SLEEP; only a re-read of the model wakes it.** + `abandon` clears the wakeup where `invalidate` raises it, because the two say + different things: an invalidation says the MODEL moved and owes a re-derivation, a + load says the PROJECT moved under a model nobody has re-read yet. `applyMode` is + `invalidateFxReclaim`'s one caller and `fxReclaimPending` gates the tick that is the + one path to any FX write or park-record spend, so every load that re-reads the model + wakes the loop on the same tick, through the reapply the reload drives — and a load + that re-reads nothing wakes nothing. `File > Revert` is exactly that load: same + `ReaProject`, same GUID, same path, so the identity poll answers `NoOp` and the model + is never re-read. Waking there was the only thing that ran, and it converged the FX + chains toward the pre-revert model and spent the park records the reverted project + still needed. The accepted cost of the sleep: an outstanding reclaim in a project + whose interrupting load was some OTHER project's waits for that project's next apply + rather than resuming on the next tick. (That a revert leaves the view model stale at + all is the identity poll's defect, not the reclaimer's — this invariant only keeps the + reclaimer from acting on it.) - **A reapply that wrote nothing leaves no undo point** — and a reapply never holds the span open, so a project open, a membership edit and `render_in_place`'s inner reapply all keep the block shape they had. The load-tick reapply diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index 7d1c4ce..abe8077 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -281,9 +281,9 @@ void fxReclaimLabelUndoSpan(const char* label, int mask) { void fxReclaimEndUndoSpan() { closeSpan(); } void fxReclaimAbandonForStateLoad() { - // Close FIRST and unconditionally. The abandon below only stops future writes; the - // block is what a Ctrl-Z would otherwise close over the wrong state, and it is open - // whether or not a drain is on the stack at all. + // Close FIRST and unconditionally. The abandon below stops the drain and puts the loop + // to sleep; the block is what a Ctrl-Z would otherwise close over the wrong state, and + // it is open whether or not a drain is on the stack at all. closeSpan(); g_queue.abandon(); } diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index 7162df7..71f2e7e 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -369,9 +369,25 @@ public: // closed. A load that keeps the SAME ReaProject (a revert) is what makes the stop // load-bearing rather than tidy: the drain's pin and cached track handle both still // validate afterwards, so nothing else would keep it out of the reloaded project. + // + // The wakeup goes DOWN, where an invalidation raises it — do not reintroduce the raise. + // An invalidation says the MODEL moved and owes a re-derivation; a load says the + // PROJECT moved under a model nobody has re-read yet, which is the opposite of a reason + // to converge. Every load that re-reads the model raises the wakeup itself, on that + // same tick, through the reapply the reload drives (applyMode is invalidateFxReclaim's + // one caller); File > Revert re-reads nothing, so waking here was the only thing that + // ever ran, and it wrote FX and spent park records against the pre-revert model. + // + // Safe to clear state under a drain, unlike invalidate(): the queue's own storage is + // empty for the drain's whole duration (run() moved it out), and run()'s abort path + // returns before it would read derived_ or commit anything. void abandon() { - invalidate(); + ++generation_; abandoned_ = true; + discard(); + pending_ = false; + // The pin STAYS — it is what the next tick's retarget tests the debounce clock + // against, and that clock outlives this exactly as it outlives an invalidation. } // Read by the shell's host on every step; false again from the next run(), because the @@ -666,8 +682,8 @@ void fxReclaimLabelUndoSpan(const char* label, int mask); void fxReclaimEndUndoSpan(); // EVERY project-state load, undo-driven or not — Ctrl-Z, Ctrl-Shift-Z, a project open, a -// File > Revert, a new project. Ends the span and stops any drain on the stack -// (FxReclaimQueue::abandon). +// File > Revert, a new project. Ends the span, stops any drain on the stack, and leaves the +// loop ASLEEP until something that re-read the model wakes it (FxReclaimQueue::abandon). // // Called from the projectconfig BeginLoadProjectState hook, which fires BEFORE REAPER // restores anything: that is the ONLY point at which the block can still close over the diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp index 6987ae1..dfb2529 100644 --- a/tests/test_fx_reclaim.cpp +++ b/tests/test_fx_reclaim.cpp @@ -222,6 +222,18 @@ FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set& liv return q; } +// One whole timer tick as the shell makes it: main.cpp's ONE pending() test, then +// fxReclaimTick's pin, its re-derivation from the session's model whenever the queue is +// stale, and the drain. The re-derivation is what makes the wakeup the load-bearing bit — +// it reads whatever model is live right now, whether or not anything re-read it since. +void timerTick(FxReclaimQueue& q, const ViewModeModel& vm, const std::set& live, + FakeHost& host) { + if (!q.pending()) return; + if (!q.beginTick(host.project)) return; + if (q.stale()) q.adopt(derive(vm, live)); + q.run(host); +} + } // namespace // -- 1. debounce --------------------------------------------------------------- @@ -1254,10 +1266,11 @@ static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack( CHECK(spanEndsAfter(r)); // the tick's own close still fires... CHECK(!span.end()); // ...and finds nothing: the terminator got there first - // The wakeup stands and the work is owed again: going idle here would strand the - // half-parked chain for the rest of the session. + // The work is owed again, and the loop is ASLEEP until something re-reads the model — + // the reapply a reload drives is what raises the wakeup, and a load that drives none + // must raise nothing (testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel). CHECK(q.stale()); - CHECK(q.pending()); + CHECK(!q.pending()); // ...and the stop belonged to that tick alone. The reloaded project's own drain writes. host.abandonAfterWrite = -1; @@ -1267,6 +1280,57 @@ static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack( CHECK(host.offlineAt("{T2}", 0)); } +static void testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel() { + // File > Revert is the load that re-reads NOTHING: it keeps the same ReaProject, so the + // session's identity poll answers NoOp, no reload follows and therefore no reapply + // does either. The terminator is the only thing that runs, so whether it raises the + // wakeup decides the whole outcome. Online work is the sharp case — undebounced, so ONE + // woken tick both writes FX and spends the record's FX half, against the very model the + // user reverted away from. The contrast with the mid-drain terminator test above: there + // the load arrives through a write's own message pump, here between two ticks entirely. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.model = &vm; + host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); + ReleaseFlush flush; + host.flush = &flush; + + FxReclaimQueue q; + CHECK(q.beginTick(kProjA)); // where an earlier tick left it: pinned, nothing owed + q.invalidate(); // applyMode, leaving the switch that brought {T} back + CHECK(q.pending()); + + q.abandon(); + CHECK(!q.pending()); + CHECK(q.stale()); + CHECK(q.items().empty()); + CHECK(q.project() == kProjA); // the pin stays: it is what guards the debounce clock + + // Timer ticks keep arriving. The queue is empty AND stale, so it is the wakeup alone + // that keeps the drain out — timerTick re-derives from the stale model exactly as the + // shell would, which is what a raise here would have handed the drain. + for (int i = 0; i < 3; ++i) timerTick(q, vm, {"{T}"}, host); + CHECK(host.writeLog.empty()); + CHECK(host.released.empty()); + CHECK(!flush.pendingFor(kProjA)); + // ...and the chain is untouched — still parked offline, as the revert left it. + CHECK(host.offlineAt("{T}", 0) && host.offlineAt("{T}", 1)); + + // The contrast, so the assertions above are about the wakeup and not about this queue + // being unable to converge: a load that DOES re-read the model reaches the reclaimer + // through its reapply's invalidate, and the very same tick over the very same model + // both writes and spends. + q.invalidate(); + timerTick(q, vm, {"{T}"}, host); + CHECK(host.writeLog.size() == 2); + CHECK(!host.offlineAt("{T}", 0) && !host.offlineAt("{T}", 1)); + CHECK(host.released.count("{T}") == 1); + CHECK(flush.pendingFor(kProjA)); +} + // -- 12. the direction the drain is moving ---------------------------------------------- static void testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold() { @@ -1413,6 +1477,7 @@ int main() { testADrainNoSwitchOpenedMintsOnePointOfItsOwn(); testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(); + testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel(); testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold(); testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures(); From 39529a5ac4b7b3bd75610fbe7a56c1e0e3f191f7 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 14:41:17 -0400 Subject: [PATCH 05/14] Design View: drop the undo blocks around writes that mint nothing; the membership actions own theirs Flag writes are silent from a timer, so a block around a reapply or the reconcile repair could only manufacture a point. The real switch's span is unchanged. --- src/shell/actions/design_view_actions.cpp | 90 ++++++++++++++++------- src/shell/view/CLAUDE.md | 73 +++++++++--------- src/shell/view/fx_reclaim.h | 12 +-- src/shell/view/view.cpp | 34 ++++----- src/shell/view/view.h | 26 ++----- src/shell/view/view_reconcile.cpp | 8 +- src/shell/view/view_reconcile.h | 6 +- tests/test_view_park.cpp | 36 +-------- 8 files changed, 141 insertions(+), 144 deletions(-) diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index e83e5e1..c8176eb 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -27,6 +27,7 @@ #include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint #include "shell/view/view.h" // applyMode (D2 shell) #include "shell/view/view_lanes.h" // mintManagedLanes +#include "shell/view/view_park.h" // kViewUndoMask #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedTracks @@ -201,40 +202,76 @@ void doActivateMode(const std::string& modeId) { bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately } +// The block each membership-edit action below owns. The reapply they drive opens none +// of its own — nothing it writes mints a point unaided — so a user-fired tag stays one +// Ctrl-Z only because the ACTION spans it, and the label names the act the user +// actually fired rather than the reapply it happened to trigger. Every one of them +// closes the block before persistViewState, which can pop a modal Save-As. Stack RAII +// so a throw between the two calls cannot leave the block open in the user's project. +class ActionUndoBlock { +public: + ActionUndoBlock(std::string label, int mask) : label_(std::move(label)), mask_(mask) { + Undo_BeginBlock2(nullptr); + } + ~ActionUndoBlock() { Undo_EndBlock2(nullptr, label_.c_str(), mask_); } + ActionUndoBlock(const ActionUndoBlock&) = delete; + ActionUndoBlock& operator=(const ActionUndoBlock&) = delete; + +private: + std::string label_; + int mask_; +}; + // tag() replaces any prior single-mode membership (a leaf lives in one mode; the // cross-mode case is show-both). void doTag(const std::string& modeId) { - for (const std::string& g : selectedTrackGuids()) - g_session->view().membership().tag(g, modeId); - reapplyActiveMode(); + const std::vector guids = selectedTrackGuids(); + if (guids.empty()) return; // nothing selected — no act, no undo point (cf. doMoveItems) + + { + ActionUndoBlock undo("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask); + for (const std::string& g : guids) g_session->view().membership().tag(g, modeId); + reapplyActiveMode(); + } persistViewState(PersistScope::Full); } // Shared body behind "Untag selected" and "Tag -> Arrange" — Arrange is the absence // of a tag, so the two actions are the same act. void doUntag() { - for (const std::string& g : selectedTrackGuids()) - g_session->view().membership().untag(g); - reapplyActiveMode(); + const std::vector guids = selectedTrackGuids(); + if (guids.empty()) return; + + { + ActionUndoBlock undo("ReaSampler: untag selected tracks", kViewUndoMask); + for (const std::string& g : guids) g_session->view().membership().untag(g); + reapplyActiveMode(); + } persistViewState(PersistScope::Full); } // Flips each track's pin independently — the honest semantics of a toggle on a // multi-selection (a mixed selection converges toward uniform only if it already was). void doShowBoth() { - MembershipIndex& m = g_session->view().membership(); - for (const std::string& g : selectedTrackGuids()) - m.setShowBoth(g, !m.isShowBoth(g)); - reapplyActiveMode(); + const std::vector guids = selectedTrackGuids(); + if (guids.empty()) return; + + { + ActionUndoBlock undo("ReaSampler: toggle show-both on selected tracks", kViewUndoMask); + MembershipIndex& m = g_session->view().membership(); + for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g)); + reapplyActiveMode(); + } persistViewState(PersistScope::Full); } // Retag the current ITEM selection to `targetMode` (empty => untag -> Arrange // default). planItemRetag decides which items to retag (manual-lane items are -// EXEMPT), upholding the managed-lanes-only invariant. Wrapped in ONE Undo block. +// EXEMPT), upholding the managed-lanes-only invariant. // -// UNDO/SAVE ordering: persistViewState may pop a Save-As dialog, which must NOT sit -// inside the Undo block, so we close the block first, then persist. +// The mask stays UNDO_STATE_ALL rather than the view mask the three track actions +// close on: this one can mint lanes, and the fixed-lane properties are only ASSUMED to +// ride UNDO_STATE_ITEMS (see kViewUndoMask). void doMoveItems(const std::string& targetMode) { const std::vector selected = selectedRetagItems(); const std::vector ops = planItemRetag(selected, targetMode); @@ -242,21 +279,20 @@ void doMoveItems(const std::string& targetMode) { MembershipIndex& membership = g_session->view().membership(); - Undo_BeginBlock2(nullptr); - for (const ItemRetagOp& op : ops) { - if (op.untag) membership.untag(op.guid); - else membership.tag(op.guid, op.modeId); + { + ActionUndoBlock undo(targetMode.empty() + ? std::string("ReaSampler: untag selected items") + : "ReaSampler: move selected items -> " + targetMode, + -1); + for (const ItemRetagOp& op : ops) { + if (op.untag) membership.untag(op.guid); + else membership.tag(op.guid, op.modeId); + } + // Re-drive the same minting/apply path auto-tag uses: mint/split lanes for any + // track whose items now span modes, then reassert active-mode lane visibility. + mintManagedLanes(g_session->view(), nullptr); + reapplyActiveMode(); } - // Re-drive the same minting/apply path auto-tag uses: mint/split lanes for any - // track whose items now span modes, then reassert active-mode lane visibility. - mintManagedLanes(g_session->view(), nullptr); - reapplyActiveMode(); - - const std::string label = - targetMode.empty() - ? std::string("ReaSampler: untag selected items") - : std::string("ReaSampler: move selected items -> ") + targetMode; - Undo_EndBlock2(nullptr, label.c_str(), -1); persistViewState(PersistScope::Full); } diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 08b40e4..03d8c10 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -64,22 +64,30 @@ decide membership or mode rules. offline/online runs on the background reclaimer (`fx_reclaim`), in ticks bounded by `kOnlineBudgetMs`/`kOfflineBudgetMs`, off `applyMode`'s block entirely (measured: `TrackFX_SetOffline` was 1897ms of a 1942ms switch). +- **`TrackFX_SetOffline` is the ONLY write in this feature that mints an undo point, + and a block around anything else can only manufacture one.** DAW-MEASURED, and the + premise every decision below rests on: ten writes of the four driven flags + (`B_SHOWINTCP`, `B_SHOWINMIXER`, `B_MAINSEND`, `I_FXEN`) from a timer callback, with + no block and no undo call, via `SetMediaTrackInfo_Value` and again via + `GetSetMediaTrackInfo`, added ZERO entries to the Undo History. Ten + `TrackFX_SetOffline` calls from a timer minted TEN points unblocked, TEN under a + block per write, and ONE under a single block spanning all ten — block WIDTH is the + entire effect there. The `("", 0)` discard form does NOT discard outside an action, + it coalesces; it discards only from a hand-fired action. A block opened on one timer + callback and closed on a LATER one holds. So: a path that writes only flags opens NO + block (a repair or a reapply must not cost the user a Ctrl-Z), and a path whose act + IS the user's opens one to make that act undoable at all — which is why the three + membership-edit actions own their own (`design_view_actions`' `ActionUndoBlock`) + rather than leaning on a reapply that now mints nothing. - **ONE mode switch is ONE undo point — the background FX parking included.** The driven flags, the lane state, AND the per-FX offline/online the reclaimer writes minutes-of-plugin-loads later all roll back in a single Ctrl-Z, because they share - ONE undo block: `applyMode` opens it (`fx_reclaim`'s `UndoSpan`) and a REAL switch - RETURNS WITH IT OPEN, for the reclaimer to close once the drain has converged. A - reapply closes its own before returning. This does not move an FX write back onto - the switch — the writes still run on the timer, off the 1897ms path. - DAW-MEASURED, and the reason the design is shaped this way: block WIDTH is the - entire effect. For ten `TrackFX_SetOffline` calls from a TIMER — unblocked, or - under a block per write, by every dispatch route — REAPER minted TEN points; under - ONE block spanning all ten it minted ONE. The `("", 0)` discard form does NOT - discard outside an action, it coalesces; it discards only from a hand-fired action. - A block opened on one timer callback and closed on a LATER one holds. Whether one - held from an ACTION into a later timer callback holds is the same property but is - `[verify — DAW]`; if it does not, the switch and its parking land as two points, - which is the intended degradation. + ONE undo block: `applyMode` opens it on a REAL SWITCH ONLY (`fx_reclaim`'s + `UndoSpan`) and RETURNS WITH IT OPEN, for the reclaimer to close once the drain has + converged. This does not move an FX write back onto the switch — the writes still + run on the timer, off the 1897ms path. Whether a block held from an ACTION into a + later timer callback holds is `[verify — DAW]`; if it does not, the switch and its + parking land as two points, which is the intended degradation. - **No path may return past a terminating condition with the span open.** A leaked block swallows every edit the user makes afterwards, indefinitely, so every terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the @@ -129,16 +137,13 @@ decide membership or mode rules. rather than resuming on the next tick. (That a revert leaves the view model stale at all is the identity poll's defect, not the reclaimer's — this invariant only keeps the reclaimer from acting on it.) -- **A reapply that wrote nothing leaves no undo point** — and a reapply never holds - the span open, so a project open, a membership edit and `render_in_place`'s inner - reapply all keep the block shape they had. The load-tick reapply - re-plans a park for every inactive leaf, and over a project saved fully parked - it finds every flag and every FX already where the plan wants them. The block - then closes on the `("", 0)` discard form (`view.h`'s `applyMintsUndoPoint`; - `mintManagedLanes`' no-op path uses the same idiom) — opening a project must - not cost the user a Ctrl-Z. A REAL switch always mints, written state or not: - it is an explicitly fired action and has to stay undoable even when the plan - found nothing to write. +- **A reapply opens no undo block at all, so it can leave no point** — whether it + wrote or not, and whoever drove it: a project open, a membership edit, + `render_in_place`'s inner reapply. Nothing a reapply writes mints unaided (see the + measurement above), so a block there could only manufacture the Ctrl-Z opening a + project must not cost. A caller whose own act needs to stay undoable spans it + itself. A REAL switch still always mints, written state or not: it is an explicitly + fired action and has to stay undoable even when the plan found nothing to write. - **A rapid A→B→A flip moves zero plugins.** The reclaimer only starts a park's OFFLINE work once a track has read continuously parked for `kParkHoldMs` (3000ms). A flip inside that window flips the flags twice but the debounce @@ -181,10 +186,10 @@ applies the resulting lane state to live tracks. ## Modules -- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and OPENS the ONE undo span all of that rides (`applyMintsUndoPoint` decides whether it leaves a point; on a real switch `fx_reclaim` is what closes it). Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. +- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and on a REAL SWITCH opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none). Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. - `view_park` — the park surface, FLAG DOMAIN ONLY. `parkTrack` captures the pre-park snapshot (flags AND the FX chain's identity-keyed offline state) when the model holds none, drives the four flags, and EXPOSEs a track that reads park-shaped with no record behind it rather than adopting it; `restoreTrack` writes the flags back from the snapshot and spends its flag half (`releaseSnapshotFlags`). Also `trackFlagParm` (the one `Flag` → REAPER-parameter mapping), `writeIfChanged` (the read-before-write every driven parameter goes through), `fxGuidString` (the one FX-identity read snapshot/reclaim both address FX through), `kViewUndoMask`, and `reportExposedTracks`/`describeExposedTracks` (named tracks, once per changed set per project). - `view_lanes` — managed fixed lanes: `mintManagedLanes` splits multi-mode content onto per-mode lanes, `applyLaneOps` drives `C_LANEPLAYS` for the active mode, `reconcileManagedLanes` recovers ownership from durable `P_LANENAME` on project open. Manual lanes are never minted, driven, renamed or reassigned. -- `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). +- `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). It opens no undo block: it runs in the wake of a Ctrl-Z, and its writes are flags only. - `view_tracks` — the scaffolding every write pass sets up: `TrackHandles`/`TrackByGuid`, `readFolderEntries`/`indexHandles`, and `UiRefreshHold` (the `PreventUIRefresh` RAII pair every write phase runs under). - `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`. - `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate; `fxReclaimAbandonForStateLoad` is the state-load terminator, which also stops the drain). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. `fxReclaimActivity` reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected `fxReclaimPending` stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible). @@ -231,9 +236,10 @@ applies the resulting lane state to live tracks. unsaved project — land inside the block after all. That is a consequence of the span, not a decision made here; it does not resolve the redo hypothesis below, which turns on capture-time semantics the SDK does not document. `[verify — DAW]` - that a Save-As dialog opening under an open undo block is harmless. The rest of - this gotcha describes the reapply path, where nothing changed. The UNDO direction - still comes + that a Save-As dialog opening under an open undo block is harmless. On the reapply + path the write stays OUTSIDE every block, as it always did: the reapply opens none, + and a membership action's own `ActionUndoBlock` closes before `persistViewState` for + exactly this modal-Save-As reason. The UNDO direction still comes out right — the point closed over the ext state as it stood BEFORE the switch, which is what a Ctrl-Z should restore — but by position, not by design. The REDO direction is what this leaves open, and the cost is worse than the @@ -282,11 +288,12 @@ applies the resulting lane state to live tracks. refusal leaves the membership tags it already wrote in place, so the next reapply — not a user re-fire of render-in-place — is what brings the tracks into sync. -- `render_in_place` still calls `applyMode` inside its own `Undo_BeginBlock2` - pair for the reapply — one point rather than two for the flags and lane state - — but with no FX writes left inside `applyMode`'s block there is nothing left - to coalesce beyond that; the SDK's nested-block coalescing itself is still - `[verify — DAW]`. +- `render_in_place` calls `applyMode` inside its own `Undo_BeginBlock2` pair, and + that block is now the only one in play on that path — the reapply opens none of + its own, so nothing nests and the SDK's undocumented nested-block coalescing + stopped being load-bearing here. The same holds for the membership actions' + `ActionUndoBlock`; `doMoveItems` is the one that still nests, over + `mintManagedLanes`. - `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays attached to its plugin across a chain mutation while parked. That is `[verify — DAW]` (see `fxGuidString` in `view_park.cpp`) and SWS issue #802 is a diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index 71f2e7e..9a4ae38 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -586,7 +586,7 @@ public: return true; } - // A mode switch decides its label only after its writes (applyMintsUndoPoint). + // A mode switch names its point only after its writes, so the open cannot carry it. void relabel(std::string label, int mask) { if (!open_) return; label_ = std::move(label); @@ -664,14 +664,14 @@ bool fxReclaimDrainInProgress(); void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj); // Opens the undo span a mode switch and its background FX parking share, so the two are -// ONE Ctrl-Z. Called by applyMode in place of its own Undo_BeginBlock2; a REAL switch -// then returns with the span still open and the reclaimer closes it once the parking has -// drained. No-op if a span is already open, so callers END first (see applyMode) rather -// than nesting. +// ONE Ctrl-Z. Called by applyMode on a REAL SWITCH ONLY — a reapply opens no block at +// all — which then returns with the span still open, and the reclaimer closes it once +// the parking has drained. No-op if a span is already open, so callers END first (see +// applyMode) rather than nesting. void fxReclaimBeginUndoSpan(ReaProject* proj); // Sets what the eventual close names the point. Separate from the begin because the -// switch's own label is only decided after its writes (applyMintsUndoPoint). +// switch's own label is only decided after its writes. void fxReclaimLabelUndoSpan(const char* label, int mask); // The ONE close, idempotent: every terminating path routes through it — the drain diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index f33d5f6..7143d62 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -2,8 +2,8 @@ // reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). // Tree arithmetic lives in view_tree (pure), one track's park/restore in view_park // and the lane writes in view_lanes; this file owns the plan iteration, parent -// visibility, and opening the one undo span they all ride (fx_reclaim's UndoSpan, -// which a real switch hands to the reclaimer still open). +// visibility, and opening the undo span a REAL switch rides (fx_reclaim's UndoSpan, +// which it hands to the reclaimer still open). #include "shell/view/view.h" @@ -115,14 +115,17 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro (targetMode ? targetMode->displayName : targetModeId) + " view"; bool laneModeChanged = false; - bool wrote = false; // anything at all this apply changed — see applyMintsUndoPoint std::vector exposedNames; // named while handles are fresh, reported after the writes // A prior switch's span, if the reclaimer has not closed it yet: this apply // supersedes it, so that parking lands as its own point rather than nesting under - // ours. Both calls are idempotent — the end reaches nothing when none is open. + // ours. Idempotent — it reaches nothing when none is open. fxReclaimEndUndoSpan(); - fxReclaimBeginUndoSpan(project); + + // ONLY a real switch opens one. Nothing a reapply writes mints on its own, so a + // block around it could only manufacture the undo point a project open must not + // cost — do not reintroduce one here (see this directory's CLAUDE.md). + if (realSwitch) fxReclaimBeginUndoSpan(project); { UiRefreshHold uiHold; // every write below lands with the TCP/MCP frozen @@ -141,7 +144,6 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro MediaTrack* tr = resolve(trackByGuid, guid); if (!tr) continue; // stale/deleted GUID — prune const TrackParkResult r = parkTrack(model, guid, tr, tp.flags); - if (r.wrote) wrote = true; // Named here, not after the block: TrackList_AdjustWindows below can // invalidate the handle. if (r.exposed) exposedNames.push_back(trackDisplayName(tr)); @@ -152,14 +154,13 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(trackByGuid, guid); if (!tr) continue; // stale/deleted GUID — prune - if (restoreTrack(model, guid, tr, tp.flags)) wrote = true; + restoreTrack(model, guid, tr, tp.flags); } // MANAGED LANES: the active mode's lane plays+shows, every other managed lane // is silenced+hidden. Empty on a D1-only project — byte-identical there. const LaneApplyResult lanes = applyLaneOps(trackByGuid, plan.lanes); laneModeChanged = lanes.freeMode; - if (lanes.wrote) wrote = true; // PARENT VISIBILITY (never parked): recomputed every toggle, never recorded. // Only the two visibility flags — never mainSend/FX on a parent. @@ -169,8 +170,8 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro MediaTrack* tr = resolve(trackByGuid, node.guid); if (!tr) continue; // stale GUID — prune const double show = visible.count(node.guid) ? 1.0 : 0.0; - if (writeIfChanged(tr, "B_SHOWINTCP", show)) wrote = true; - if (writeIfChanged(tr, "B_SHOWINMIXER", show)) wrote = true; + writeIfChanged(tr, "B_SHOWINTCP", show); + writeIfChanged(tr, "B_SHOWINMIXER", show); } model.setActiveMode(targetModeId); @@ -197,14 +198,11 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro // this apply (SDK requirement for I_FREEMODE changes). if (laneModeChanged) UpdateTimeline(); - const bool mint = applyMintsUndoPoint(realSwitch, wrote); - fxReclaimLabelUndoSpan(mint ? undoLabel.c_str() : "", mint ? kViewUndoMask : 0); - - // A REAL switch returns with the span STILL OPEN: the reclaimer closes it once the - // parking this apply is about to invalidate has drained, so the switch and its FX - // writes are ONE Ctrl-Z. Everything else closes here as it always did — a reapply is - // not a switch, and the load tick must not leave a block open across a project open. - if (!realSwitch) fxReclaimEndUndoSpan(); + // A real switch mints whether or not the plan found anything to write — it is an + // explicitly fired action and has to stay undoable — and RETURNS WITH THE SPAN STILL + // OPEN: the reclaimer closes it once the parking this apply is about to invalidate + // has drained, so the switch and its FX writes are ONE Ctrl-Z. + if (realSwitch) fxReclaimLabelUndoSpan(undoLabel.c_str(), kViewUndoMask); // Neither of the two below is a project write — a console message and an in-memory // fingerprint — so whether the span is still open over them is immaterial. diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 643f7ba..8c3c973 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -1,9 +1,9 @@ #pragma once // REAPER-facing shell of Design View: reads the live folder tree, runs // ViewModeModel's pure planner, and applies the resulting flag / lane / parent -// writes under ONE undo block. See src/shell/view/CLAUDE.md for the enforced -// invariants (never touch master/mute, solo cached per mode and never lost, -// record-based restore). +// writes — under ONE undo block on a real switch, under none at all on a reapply. +// See src/shell/view/CLAUDE.md for the enforced invariants (never touch +// master/mute, solo cached per mode and never lost, record-based restore). #include @@ -22,20 +22,6 @@ namespace reasampler { // `nullptr` meaning the current project here is inferred by analogy, not confirmed. bool transportBlocksModeSwitch(ReaProject* proj); -// Does one applyMode leave an undo point behind? -// -// A REAPPLY that wrote nothing must not: the load-tick reapply over a project saved -// fully parked re-plans a park for every inactive leaf and finds every flag already -// where it wants them, so a point there would mean opening a project costs an undo -// step. `Undo_EndBlock2(proj, "", 0)` is the discard form (mintManagedLanes' no-op -// path and the reconcile pass use the same idiom). -// -// A real switch mints regardless, fail-safe: it is an explicitly fired action and has -// to stay undoable even in the degenerate case where the plan found nothing to write. -inline bool applyMintsUndoPoint(bool realSwitch, bool wroteAnything) { - return realSwitch || wroteAnything; -} - // True while an applyMode call is on the stack. applyMode fails closed when // re-entered, and this lets a caller with no fallback test BEFORE it spends a // one-shot signal on a call that would be refused — see main.cpp's load glue. @@ -45,7 +31,8 @@ bool modeApplyInProgress(); // the outgoing mode's solo state and replays the incoming mode's, runs planToggle, // applies the park/restore flag writes plus managed-lane and parent-visibility // writes, then sets the active mode. ONE switch is ONE undo point; everything it -// moved rolls back in a single Ctrl-Z. +// moved rolls back in a single Ctrl-Z. A REAPPLY leaves no point at all — it opens no +// block, because none of what it writes mints one on its own. // // FX are NOT written here. Offlining a parked chain is the reclaimer's job, off this // path: TrackFX_SetOffline was 1897ms of a measured 1942ms switch, against 0.02ms for @@ -53,7 +40,8 @@ bool modeApplyInProgress(); // // A REAL switch therefore RETURNS WITH ITS UNDO BLOCK STILL OPEN (fx_reclaim's // UndoSpan): the reclaimer closes it once the parking has drained, which is what keeps -// the switch and its FX writes ONE point rather than two. A reapply closes its own. +// the switch and its FX writes ONE point rather than two. A reapply opens none, so a +// caller that wants its own act undoable opens its own (design_view_actions does). // // Returns false (no mutation) if `targetModeId` isn't registered, if this is a real // switch (target != active) while the transport is running, if it is re-entered diff --git a/src/shell/view/view_reconcile.cpp b/src/shell/view/view_reconcile.cpp index 6d605e8..0acb8a0 100644 --- a/src/shell/view/view_reconcile.cpp +++ b/src/shell/view/view_reconcile.cpp @@ -14,8 +14,6 @@ #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_TrackList_AdjustWindows -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_ValidatePtr2 #include "reaper_plugin_functions.h" @@ -58,8 +56,11 @@ void reconcileAppliedFlags(ViewModeModel& model, ReaProject* proj) { readFolderEntries(project, handleByGuid); const TrackByGuid trackByGuid = indexHandles(handleByGuid); + // NO undo block, deliberately: this runs in the wake of a Ctrl-Z, and the flag + // writes below mint nothing on their own — a block could only manufacture the point + // that would become the thing the user's next Ctrl-Z undoes. Do not reintroduce one + // (see this directory's CLAUDE.md). bool wrote = false; - Undo_BeginBlock2(project); { UiRefreshHold uiHold; for (const TrackFlagOp& op : result.ops) { @@ -69,7 +70,6 @@ void reconcileAppliedFlags(ViewModeModel& model, ReaProject* proj) { wrote = true; } } - Undo_EndBlock2(project, "", 0); // discard form — see the header if (wrote) { TrackList_AdjustWindows(false); // tracks reappearing owe the full relayout diff --git a/src/shell/view/view_reconcile.h b/src/shell/view/view_reconcile.h index 6c33328..07caa33 100644 --- a/src/shell/view/view_reconcile.h +++ b/src/shell/view/view_reconcile.h @@ -85,9 +85,9 @@ inline ProjectReconcileResult planReconcilePerProject( // once an undo has rolled the model back underneath it. void noteAppliedState(ReaProject* proj, const ViewModeModel& model); -// Runs planReconcile over the reloaded `model` and writes its ops. The undo block it -// opens is deliberately DISCARDED — this runs in the wake of a Ctrl-Z, and a repair -// that minted a point would become the thing the user's next Ctrl-Z undoes. +// Runs planReconcile over the reloaded `model` and writes its ops. It opens NO undo +// block — this runs in the wake of a Ctrl-Z, and a repair that minted a point would +// become the thing the user's next Ctrl-Z undoes. void reconcileAppliedFlags(ViewModeModel& model, ReaProject* proj); } // namespace reasampler diff --git a/tests/test_view_park.cpp b/tests/test_view_park.cpp index c8ef44a..5bed250 100644 --- a/tests/test_view_park.cpp +++ b/tests/test_view_park.cpp @@ -2,12 +2,10 @@ // test framework. // // The properties under test: the four flags a park drives address exactly the four -// REAPER parameters the tool owns (never mute, never solo), and one mode apply leaves -// at most ONE undo point — none at all when a reapply found everything already where -// it wanted it. +// REAPER parameters the tool owns (never mute, never solo), the re-park union, the +// park-shaped chain read, and the EXPOSE report's wording. #include "../src/shell/view/view_park.h" -#include "../src/shell/view/view.h" // applyMintsUndoPoint — the apply's undo-point fold #include "core/view/view_mode_model.h" // makeParkPlan — the park flags' only producer @@ -65,32 +63,6 @@ static void testAParkPlanDrivesEveryDrivenFlagToZero() { CHECK(parms.size() == 4); } -// -- the apply's one undo point ---------------------------------------------- -// -// These pin applyMintsUndoPoint's fold only. The `wroteAnything` verdict it consumes -// — writeIfChanged / parkTrack / restoreTrack, all running against live REAPER calls -// — cannot be pinned here; docs/VERIFICATION.md §"Mode switching" is the sole cover -// for that plumbing being wired correctly. - -static void testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite() { - // An explicitly fired action stays undoable even when the plan found nothing to - // write — the discard form is for reapplies only. - CHECK(applyMintsUndoPoint(/*realSwitch=*/true, /*wroteAnything=*/false)); - CHECK(applyMintsUndoPoint(/*realSwitch=*/true, /*wroteAnything=*/true)); -} - -static void testAReapplyThatWroteSomethingLeavesAPoint() { - // A tag/untag reapply that actually parked a track: real project state moved. - CHECK(applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/true)); -} - -static void testAReapplyThatWroteNothingLeavesNoPointAtAll() { - // The project-load reapply over a project saved fully parked: every flag already - // sits where the plan wants it. Opening a project must not cost the user an undo - // step. - CHECK(!applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/false)); -} - // -- the re-park union: a plugin added between a restore and the next park -------- // // Sequence under test: park -> restore -> user adds plugin P -> re-park. Without the @@ -216,10 +188,6 @@ int main() { testNoDrivenFlagCanEverAddressMuteOrSolo(); testAParkPlanDrivesEveryDrivenFlagToZero(); - testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite(); - testAReapplyThatWroteSomethingLeavesAPoint(); - testAReapplyThatWroteNothingLeavesNoPointAtAll(); - testAPluginAddedWhileRestoredJoinsTheUnionRatherThanBeingDropped(); testAPluginRemovedWhileRestoredStaysInTheUnionForResolveFxRestoreToDrop(); From b2f6b3f1d0b01b9f15a1cbe9cc85462747f402ce Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 15:13:52 -0400 Subject: [PATCH 06/14] Design View: no undo block may open over the reclaim span, and a reapply closes none Every opener ends the span first, so a tag fired mid-drain no longer hijacks the switch's point. One stated criterion governs both lane sites; no-op actions discard. --- src/shell/actions/design_view_actions.cpp | 60 ++++++++++---- src/shell/capture/render_in_place.cpp | 6 ++ src/shell/view/CLAUDE.md | 95 ++++++++++++++++++----- src/shell/view/view.cpp | 49 +++++++----- src/shell/view/view.h | 27 ++++++- src/shell/view/view_lanes.cpp | 6 ++ src/shell/view/view_lanes.h | 18 +++-- src/shell/view/view_park.cpp | 5 +- src/shell/view/view_park.h | 20 +++-- 9 files changed, 210 insertions(+), 76 deletions(-) diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index c8176eb..2147866 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -25,6 +25,7 @@ #include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) #include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key #include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint +#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — no block opens over the span #include "shell/view/view.h" // applyMode (D2 shell) #include "shell/view/view_lanes.h" // mintManagedLanes #include "shell/view/view_park.h" // kViewUndoMask @@ -105,12 +106,16 @@ std::vector selectedTrackGuids() { } // Reapplies the CURRENT active mode so a membership mutation takes visible effect -// immediately (park/unpark/re-derive parents). The return is deliberately discarded: a -// refused reapply leaves the membership edit standing and unapplied, and the user's next -// edit or mode switch reapplies it. What must NOT be dropped with it is the reclaimer's -// queue, which is why applyMode invalidates on its refusal path too (view.cpp). -void reapplyActiveMode() { - applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); +// immediately (park/unpark/re-derive parents), answering whether it wrote any project +// state — the caller's block mints only if something did. `applied` is deliberately +// discarded: a refused reapply leaves the membership edit standing and unapplied, and +// the user's next edit or mode switch reapplies it (a refusal writes nothing, so it +// reports false here and the block discards). What must NOT be dropped with it is the +// reclaimer's queue, which is why applyMode invalidates on its refusal path too +// (view.cpp). +bool reapplyActiveMode() { + return applyMode(g_session->view(), g_session->view().activeModeId(), nullptr) + .wroteProjectState; } constexpr int kFreeModeFixedLanes = 2; // I_FREEMODE value; mirrors the shell's constant @@ -189,15 +194,21 @@ void doToggleMode() { const std::string target = nextModeId(g_session->view().modes(), g_session->view().activeModeId()); if (target.empty()) return; // no modes to cycle to (degenerate) - if (!applyMode(g_session->view(), target, nullptr)) { reportModeSwitchRefused(); return; } + if (!applyMode(g_session->view(), target, nullptr).applied) { + reportModeSwitchRefused(); + return; + } persistViewState(PersistScope::ViewOnly); bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately } -// Direct jump to a named mode. applyMode is a no-op (returns false, no mutation) if +// Direct jump to a named mode. applyMode is a no-op (`applied` false, no mutation) if // the id is unregistered or the transport is running, so both fail safe. void doActivateMode(const std::string& modeId) { - if (!applyMode(g_session->view(), modeId, nullptr)) { reportModeSwitchRefused(); return; } + if (!applyMode(g_session->view(), modeId, nullptr).applied) { + reportModeSwitchRefused(); + return; + } persistViewState(PersistScope::ViewOnly); bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately } @@ -208,18 +219,34 @@ void doActivateMode(const std::string& modeId) { // actually fired rather than the reapply it happened to trigger. Every one of them // closes the block before persistViewState, which can pop a modal Save-As. Stack RAII // so a throw between the two calls cannot leave the block open in the user's project. +// +// ENDING THE RECLAIM SPAN IS PART OF OPENING, and every action here inherits it from +// this one constructor. A mode switch fired seconds ago has left the span open; a block +// opened over it makes each close pair with the other's open, collapsing the switch +// into this tag — mechanism and full rule in src/shell/view/CLAUDE.md. +// +// `markWrote` decides whether this block mints at all: re-tagging tracks that already +// sit in that mode writes nothing to the project, and the ("", 0) discard form is real +// from a hand-fired action (DAW-measured, same doc). The membership move itself is +// never in the point either way — persistViewState runs after the close. class ActionUndoBlock { public: ActionUndoBlock(std::string label, int mask) : label_(std::move(label)), mask_(mask) { + fxReclaimEndUndoSpan(); Undo_BeginBlock2(nullptr); } - ~ActionUndoBlock() { Undo_EndBlock2(nullptr, label_.c_str(), mask_); } + ~ActionUndoBlock() { + if (wrote_) Undo_EndBlock2(nullptr, label_.c_str(), mask_); + else Undo_EndBlock2(nullptr, "", 0); + } + void markWrote() { wrote_ = true; } ActionUndoBlock(const ActionUndoBlock&) = delete; ActionUndoBlock& operator=(const ActionUndoBlock&) = delete; private: std::string label_; int mask_; + bool wrote_ = false; }; // tag() replaces any prior single-mode membership (a leaf lives in one mode; the @@ -231,7 +258,7 @@ void doTag(const std::string& modeId) { { ActionUndoBlock undo("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask); for (const std::string& g : guids) g_session->view().membership().tag(g, modeId); - reapplyActiveMode(); + if (reapplyActiveMode()) undo.markWrote(); } persistViewState(PersistScope::Full); } @@ -245,7 +272,7 @@ void doUntag() { { ActionUndoBlock undo("ReaSampler: untag selected tracks", kViewUndoMask); for (const std::string& g : guids) g_session->view().membership().untag(g); - reapplyActiveMode(); + if (reapplyActiveMode()) undo.markWrote(); } persistViewState(PersistScope::Full); } @@ -260,7 +287,7 @@ void doShowBoth() { ActionUndoBlock undo("ReaSampler: toggle show-both on selected tracks", kViewUndoMask); MembershipIndex& m = g_session->view().membership(); for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g)); - reapplyActiveMode(); + if (reapplyActiveMode()) undo.markWrote(); } persistViewState(PersistScope::Full); } @@ -290,8 +317,11 @@ void doMoveItems(const std::string& targetMode) { } // Re-drive the same minting/apply path auto-tag uses: mint/split lanes for any // track whose items now span modes, then reassert active-mode lane visibility. - mintManagedLanes(g_session->view(), nullptr); - reapplyActiveMode(); + // Either half writing is enough to mint; a retag that split no lanes and drove + // no flag leaves nothing for a point to hold. + const bool minted = mintManagedLanes(g_session->view(), nullptr); + const bool reapplied = reapplyActiveMode(); + if (minted || reapplied) undo.markWrote(); } persistViewState(PersistScope::Full); diff --git a/src/shell/capture/render_in_place.cpp b/src/shell/capture/render_in_place.cpp index dfaf22c..cab8935 100644 --- a/src/shell/capture/render_in_place.cpp +++ b/src/shell/capture/render_in_place.cpp @@ -21,6 +21,7 @@ #include "shell/capture/track_guid.h" // guidString #include "shell/panel/panel_input.h" // bankPanelTailSetting #include "shell/persist/session.h" +#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan #include "shell/view/view.h" // applyMode #include "shell/view/view_lanes.h" // mintManagedLanes @@ -133,6 +134,11 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) { // visible — never to choose a tag. Both tags below are absolute. const std::string activeMode = session.view().activeModeId(); + // A mode switch fired seconds ago has left the FX reclaimer's undo span open, and + // no block may open over it — mechanism in src/shell/view/CLAUDE.md. Ending it here + // mints the switch's point, whole, before this block exists; idempotent otherwise. + fxReclaimEndUndoSpan(); + Undo_BeginBlock2(nullptr); // flags = 0, never 1: flags&1 adds default envelopes/FX, and a default chain diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 03d8c10..2c5abdb 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -74,11 +74,34 @@ decide membership or mode rules. block per write, and ONE under a single block spanning all ten — block WIDTH is the entire effect there. The `("", 0)` discard form does NOT discard outside an action, it coalesces; it discards only from a hand-fired action. A block opened on one timer - callback and closed on a LATER one holds. So: a path that writes only flags opens NO - block (a repair or a reapply must not cost the user a Ctrl-Z), and a path whose act - IS the user's opens one to make that act undoable at all — which is why the three - membership-edit actions own their own (`design_view_actions`' `ActionUndoBlock`) - rather than leaning on a reapply that now mints nothing. + callback and closed on a LATER one holds. + **The four flags above are the whole of what was measured.** The lane parameters this + directory also writes with no block behind them — `C_LANEPLAYS`, `I_FREEMODE`, + `C_LANESCOLLAPSED`, `C_LANESETTINGS` (`view_lanes`' `applyLaneOps`) — are assumed + silent BY INFERENCE from the four, never observed: `[verify — DAW]`. If they turn out + to mint unaided, a reapply that repairs a lane leaves a stray point the criterion + below did not intend; that is the failure mode to look for. +- **ONE CRITERION decides who opens an undo block: the path whose own act has to + survive as a point, and no other.** Two things make an act the path's own — it was + explicitly fired by the user (a real mode switch, the membership actions, + `render_in_place`), or it makes a STRUCTURAL project change no re-derivation + reproduces (`view_lanes`' `mintManagedLanes`, which creates lanes and re-homes items; + the panel's detect tick fires it, so it is nobody's action, and it opens a block + anyway because nothing else would ever put those lanes back). Everything else + RE-ASSERTS driven state the view model recomputes from membership on every apply — + the park/restore flags, parent visibility, and `applyLaneOps`' `C_LANEPLAYS` plus its + `I_FREEMODE` re-assert on a track that already owns managed lanes — and opens none, + because a block there could only manufacture the point a project open or a membership + edit must not cost. This is why the same `I_FREEMODE` write is blocked at one site and + not the other: minting SPLITS a track, `applyLaneOps` only re-states which lane plays. + Second clause, binding every site that does open one: **a block that wrote nothing + closes on the discard form** (`Undo_EndBlock2(proj, "", 0)` — real from a hand-fired + action, per the measurement above), so re-tagging tracks already in that mode leaves + no empty point. The one exception is a REAL SWITCH, which mints written state or not: + it is an explicitly fired action and has to stay undoable even where the plan found + nothing to write. "Wrote" means a REAPER write throughout — model moves persist + through `persistViewState`, outside every block, so no point could carry them + (`ModeApplyResult`, `view.h`). - **ONE mode switch is ONE undo point — the background FX parking included.** The driven flags, the lane state, AND the per-FX offline/online the reclaimer writes minutes-of-plugin-loads later all roll back in a single Ctrl-Z, because they share @@ -92,12 +115,37 @@ decide membership or mode rules. block swallows every edit the user makes afterwards, indefinitely, so every terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the drain settling, aborting on a generation bump, or deferring on the transport; the - project retargeting or closing; the next apply superseding it; ANY PROJECT-STATE - LOAD; a project save; extension unload. The span may stay open only across a wait it + project retargeting or closing; the next REAL SWITCH superseding it; ANY PROJECT-STATE + LOAD; a project save; extension unload; and any path about to open a block of its own + (next invariant). The span may stay open only across a wait it can BOUND — the 3s debounce, which is the dead time between the switch and its first FX write, and the only reason the span exists. A TRANSPORT deferral is not bounded (it lasts as long as the playback pass), so it ends the span and the parking that follows mints its own point (`spanEndsAfter`). +- **NOTHING MAY OPEN AN UNDO BLOCK WHILE THE SPAN IS OPEN — every opener ends it + first, as its first act.** REAPER's blocks are read as a DEPTH COUNTER, not a stack + of identities (`[verify — DAW]`, and the same undocumented nested-block behaviour the + Gotchas below name): `Undo_EndBlock2` at depth 2->1 mints nothing and drops the label + and mask it was handed, and the close that does reach depth 0 mints under whatever + label IT carries. So a block opened over the span leaves each close paired with the + other's open — the switch's point gone, its parking relabelled as the intruder's act, + and the `UNDO_STATE_FX` the drain widened the span to (`UndoSpan::widen`) dropped, + which is the exact loss the widen exists to prevent. The RULE does not rest on that + reading: ending the span first yields the switch's point and the opener's as two + correctly-labelled points under the counter reading AND under an identity-pairing one, + which is why it is stated as an unconditional discipline rather than a workaround for + a behaviour nobody has observed. Ending it first mints the switch's point whole, at + depth 0, and leaves the opener's block the only one in play. The + openers, all of them: `design_view_actions`' `ActionUndoBlock` (whose constructor + ends the span, so all four membership actions inherit it), `view_lanes`' + `mintManagedLanes`, and `shell/capture`'s `render_in_place`. The mirror half is + `applyMode`'s: a REAPPLY never closes a span, precisely because it is reachable from + inside one of those blocks — only a real switch supersedes, and a real switch is + always fired at depth 0. RESIDUAL, out of this rule's reach: an extension undo block + ELSEWHERE (a capture, a bank verb, an ingest) that pumps the message loop can re-enter + the timer, and a drain tick that settles in there closes the span at depth 2->1 with + the same result. Closing that needs every extension-owned `Undo_BeginBlock2` to route + through one opener that ends the span — not attempted here. - **EVERY project-state load terminates the span itself, at the hook, before REAPER restores anything — `isUndo` does not narrow it.** `fxReclaimAbandonForStateLoad` runs UNBRANCHED from `BeginLoadProjectState` @@ -137,13 +185,13 @@ decide membership or mode rules. rather than resuming on the next tick. (That a revert leaves the view model stale at all is the identity poll's defect, not the reclaimer's — this invariant only keeps the reclaimer from acting on it.) -- **A reapply opens no undo block at all, so it can leave no point** — whether it - wrote or not, and whoever drove it: a project open, a membership edit, - `render_in_place`'s inner reapply. Nothing a reapply writes mints unaided (see the - measurement above), so a block there could only manufacture the Ctrl-Z opening a - project must not cost. A caller whose own act needs to stay undoable spans it - itself. A REAL switch still always mints, written state or not: it is an explicitly - fired action and has to stay undoable even when the plan found nothing to write. +- **A reapply opens no undo block and CLOSES NO SPAN, so it can leave no point** — + whether it wrote or not, and whoever drove it: a project open, a membership edit, + `render_in_place`'s inner reapply. The open half is the criterion above (a reapply + re-asserts derived state and is nobody's act); the close half is the nesting rule + above (a reapply runs inside its driver's block, so a supersede-close from there + would land at depth 2->1 and hijack it). It reports what it wrote instead, and the + driver holding the block decides — `ModeApplyResult::wroteProjectState`. - **A rapid A→B→A flip moves zero plugins.** The reclaimer only starts a park's OFFLINE work once a track has read continuously parked for `kParkHoldMs` (3000ms). A flip inside that window flips the flags twice but the debounce @@ -186,7 +234,7 @@ applies the resulting lane state to live tracks. ## Modules -- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and on a REAL SWITCH opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none). Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. +- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and on a REAL SWITCH ends any prior span and opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none and closes none). Reports what it wrote (`ModeApplyResult`) so a caller holding its own block can discard an empty point. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. - `view_park` — the park surface, FLAG DOMAIN ONLY. `parkTrack` captures the pre-park snapshot (flags AND the FX chain's identity-keyed offline state) when the model holds none, drives the four flags, and EXPOSEs a track that reads park-shaped with no record behind it rather than adopting it; `restoreTrack` writes the flags back from the snapshot and spends its flag half (`releaseSnapshotFlags`). Also `trackFlagParm` (the one `Flag` → REAPER-parameter mapping), `writeIfChanged` (the read-before-write every driven parameter goes through), `fxGuidString` (the one FX-identity read snapshot/reclaim both address FX through), `kViewUndoMask`, and `reportExposedTracks`/`describeExposedTracks` (named tracks, once per changed set per project). - `view_lanes` — managed fixed lanes: `mintManagedLanes` splits multi-mode content onto per-mode lanes, `applyLaneOps` drives `C_LANEPLAYS` for the active mode, `reconcileManagedLanes` recovers ownership from durable `P_LANENAME` on project open. Manual lanes are never minted, driven, renamed or reassigned. - `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). It opens no undo block: it runs in the wake of a Ctrl-Z, and its writes are flags only. @@ -288,12 +336,17 @@ applies the resulting lane state to live tracks. refusal leaves the membership tags it already wrote in place, so the next reapply — not a user re-fire of render-in-place — is what brings the tracks into sync. -- `render_in_place` calls `applyMode` inside its own `Undo_BeginBlock2` pair, and - that block is now the only one in play on that path — the reapply opens none of - its own, so nothing nests and the SDK's undocumented nested-block coalescing - stopped being load-bearing here. The same holds for the membership actions' - `ActionUndoBlock`; `doMoveItems` is the one that still nests, over - `mintManagedLanes`. +- **"Nothing nests on the reapply paths" was FALSE, and cost the switch its undo + point.** `render_in_place` and the membership actions each call `applyMode` inside + their own `Undo_BeginBlock2` pair; the reapply opens no block of its own, but a prior + REAL SWITCH's span can still be open — for `kParkHoldMs` plus the whole drain — and + it is a block like any other. Two things nest there, and both are now handled by the + "nothing may open a block while the span is open" invariant: the caller's block over + the span (each caller ends the span first), and the reapply's former unconditional + supersede-close inside the caller's block (only a real switch closes now). What still + nests legitimately: `doMoveItems` and `render_in_place` over `mintManagedLanes`, both + properly paired inside a single outer block, so the SDK's undocumented nested-block + coalescing is load-bearing only for that shape. - `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays attached to its plugin across a chain mutation while parked. That is `[verify — DAW]` (see `fxGuidString` in `view_park.cpp`) and SWS issue #802 is a diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 7143d62..b9ddb6f 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -63,19 +63,20 @@ bool modeApplyInProgress() { return g_applying; } namespace { -bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) { - if (g_applying) return false; // see ApplyLatch; same fail-closed shape as the guards below +ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetModeId, + ReaProject* proj) { + if (g_applying) return {}; // see ApplyLatch; same fail-closed shape as the guards below ApplyLatch latch; if (!model.modes().contains(targetModeId)) { - return false; // reject before touching the project — no partial apply + return {}; // reject before touching the project — no partial apply } // Pinned ONCE: resolving "current project" independently at the begin and the end // would let a tab switch mid-apply open the block on one project and close it on // another. ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0); - if (!ValidatePtr2(nullptr, project, "ReaProject*")) return false; + if (!ValidatePtr2(nullptr, project, "ReaProject*")) return {}; // The discriminator behind both halves of the contract in view.h. A gate placed // unconditionally here would break tagging and the project-load reapply during @@ -85,7 +86,7 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro const bool realSwitch = targetModeId != outgoingModeId; if (realSwitch && transportBlocksModeSwitch(project)) { - return false; // same fail-closed shape as the mode-exists guard above + return {}; // same fail-closed shape as the mode-exists guard above } TrackHandles handleByGuid; @@ -115,17 +116,20 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro (targetMode ? targetMode->displayName : targetModeId) + " view"; bool laneModeChanged = false; + bool wrote = false; // REAPER writes only — see ModeApplyResult std::vector exposedNames; // named while handles are fresh, reported after the writes - // A prior switch's span, if the reclaimer has not closed it yet: this apply - // supersedes it, so that parking lands as its own point rather than nesting under - // ours. Idempotent — it reaches nothing when none is open. - fxReclaimEndUndoSpan(); - - // ONLY a real switch opens one. Nothing a reapply writes mints on its own, so a - // block around it could only manufacture the undo point a project open must not - // cost — do not reintroduce one here (see this directory's CLAUDE.md). - if (realSwitch) fxReclaimBeginUndoSpan(project); + // ONLY A REAL SWITCH, both halves — end a prior switch's still-open span, then open + // this one's. A REAPPLY must do neither: it opens none because nothing it writes + // mints unaided, and it CLOSES none because it is reachable from inside a caller's + // own block (a membership action, render_in_place), where a close would land at + // depth 2->1 and hijack the switch's point instead of minting it. A caller that + // opens a block ends the span itself, first, at depth 0 — see this directory's + // CLAUDE.md. Both calls are idempotent; do not reintroduce either unconditionally. + if (realSwitch) { + fxReclaimEndUndoSpan(); + fxReclaimBeginUndoSpan(project); + } { UiRefreshHold uiHold; // every write below lands with the TCP/MCP frozen @@ -144,6 +148,7 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro MediaTrack* tr = resolve(trackByGuid, guid); if (!tr) continue; // stale/deleted GUID — prune const TrackParkResult r = parkTrack(model, guid, tr, tp.flags); + if (r.wrote) wrote = true; // Named here, not after the block: TrackList_AdjustWindows below can // invalidate the handle. if (r.exposed) exposedNames.push_back(trackDisplayName(tr)); @@ -154,13 +159,14 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(trackByGuid, guid); if (!tr) continue; // stale/deleted GUID — prune - restoreTrack(model, guid, tr, tp.flags); + if (restoreTrack(model, guid, tr, tp.flags)) wrote = true; } // MANAGED LANES: the active mode's lane plays+shows, every other managed lane // is silenced+hidden. Empty on a D1-only project — byte-identical there. const LaneApplyResult lanes = applyLaneOps(trackByGuid, plan.lanes); laneModeChanged = lanes.freeMode; + if (lanes.wrote) wrote = true; // PARENT VISIBILITY (never parked): recomputed every toggle, never recorded. // Only the two visibility flags — never mainSend/FX on a parent. @@ -170,8 +176,8 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro MediaTrack* tr = resolve(trackByGuid, node.guid); if (!tr) continue; // stale GUID — prune const double show = visible.count(node.guid) ? 1.0 : 0.0; - writeIfChanged(tr, "B_SHOWINTCP", show); - writeIfChanged(tr, "B_SHOWINMIXER", show); + if (writeIfChanged(tr, "B_SHOWINTCP", show)) wrote = true; + if (writeIfChanged(tr, "B_SHOWINMIXER", show)) wrote = true; } model.setActiveMode(targetModeId); @@ -212,13 +218,14 @@ bool applyModeImpl(ViewModeModel& model, const std::string& targetModeId, ReaPro // an undo rolling the model back underneath it. In memory, never on disk. noteAppliedState(project, model); - return true; + return ModeApplyResult{true, wrote}; } } // namespace -bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) { - const bool applied = applyModeImpl(model, targetModeId, proj); +ModeApplyResult applyMode(ViewModeModel& model, const std::string& targetModeId, + ReaProject* proj) { + const ModeApplyResult result = applyModeImpl(model, targetModeId, proj); // Every membership, snapshot and active-mode mutation in the system reaches the // project through this call, so this is the whole invalidation surface: the @@ -231,7 +238,7 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject // else would drop a queue that now converges toward the mode the user just left. // The cost when the refusal followed no mutation is one re-derivation on the timer. invalidateFxReclaim(); - return applied; + return result; } } // namespace reasampler diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 8c3c973..3df9b7b 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -27,6 +27,21 @@ bool transportBlocksModeSwitch(ReaProject* proj); // one-shot signal on a call that would be refused — see main.cpp's load glue. bool modeApplyInProgress(); +// What one applyMode did. +// +// `wroteProjectState` counts REAPER writes ONLY — a driven flag, a lane parameter, +// a parent's visibility. Model moves (a snapshot stored, a record's flag half spent, +// the active mode set) are deliberately excluded: they reach disk through +// persistViewState, which every caller runs OUTSIDE its undo block, so no point can +// carry them and counting them would only mint an empty one. It is the answer a +// caller holding its own block needs for the discard decision (design_view_actions' +// ActionUndoBlock); the solo pair cannot affect it, since only a real switch drives +// solo and a real switch mints regardless. +struct ModeApplyResult { + bool applied = false; + bool wroteProjectState = false; +}; + // Captures each about-to-park track's prior flag values into `model`, caches/clears // the outgoing mode's solo state and replays the incoming mode's, runs planToggle, // applies the park/restore flag writes plus managed-lane and parent-visibility @@ -41,16 +56,20 @@ bool modeApplyInProgress(); // A REAL switch therefore RETURNS WITH ITS UNDO BLOCK STILL OPEN (fx_reclaim's // UndoSpan): the reclaimer closes it once the parking has drained, which is what keeps // the switch and its FX writes ONE point rather than two. A reapply opens none, so a -// caller that wants its own act undoable opens its own (design_view_actions does). +// caller that wants its own act undoable opens its own (design_view_actions does) — +// and, because REAPER's blocks are a depth counter, ENDS THE SPAN BEFORE IT OPENS +// THAT BLOCK. A reapply never closes a span, precisely because it can be running +// inside such a caller's block. // -// Returns false (no mutation) if `targetModeId` isn't registered, if this is a real -// switch (target != active) while the transport is running, if it is re-entered +// `applied` is false (no mutation) if `targetModeId` isn't registered, if this is a +// real switch (target != active) while the transport is running, if it is re-entered // (modeApplyInProgress), or if the project does not validate. A reapply (target == // active) is never gated and never touches solo. `proj` == nullptr means the current // project, resolved ONCE and used for every call this apply makes. // // Either way — applied or refused — this drops the FX reclaimer's derived queue, which // is why callers may safely discard the return after mutating the model. -bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj); +ModeApplyResult applyMode(ViewModeModel& model, const std::string& targetModeId, + ReaProject* proj); } // namespace reasampler diff --git a/src/shell/view/view_lanes.cpp b/src/shell/view/view_lanes.cpp index 087589c..982acce 100644 --- a/src/shell/view/view_lanes.cpp +++ b/src/shell/view/view_lanes.cpp @@ -16,6 +16,7 @@ #include "core/view/lane_keys.h" #include "core/view/view_tree.h" #include "shell/capture/item_read.h" +#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — no block opens over the span #include "shell/view/view_park.h" // writeIfChanged — the read-before-write every driven parm takes #define REAPERAPI_MINIMAL @@ -276,6 +277,11 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { const TrackByGuid trackByGuid = indexHandles(handleByGuid); + // No block may open over a still-open reclaim span — mechanism in this directory's + // CLAUDE.md. Placed past the empty-plan return above deliberately: a detect tick + // that mints nothing must not shorten a span it never competes with. + fxReclaimEndUndoSpan(); + // `proj` deliberately NOT pinned the way applyMode pins it: nothing between this // begin and its end pumps the message loop, so no tab switch can land between them. Undo_BeginBlock2(proj); diff --git a/src/shell/view/view_lanes.h b/src/shell/view/view_lanes.h index 6fd1311..b8adcc6 100644 --- a/src/shell/view/view_lanes.h +++ b/src/shell/view/view_lanes.h @@ -15,8 +15,8 @@ class ReaProject; namespace reasampler { // `freeMode` alone decides whether UpdateTimeline is owed (SDK: required after an -// I_FREEMODE change); `wrote` is any project write at all, which is what the apply's -// undo-point decision reads. +// I_FREEMODE change); `wrote` is a REAPER write, the reading ModeApplyResult +// (view.h) carries through to the caller's undo-point decision. struct LaneApplyResult { bool freeMode = false; bool wrote = false; @@ -24,14 +24,22 @@ struct LaneApplyResult { // Reconciles each op's durable laneKey to the track's current ordinal (a // stale/renamed/deleted key is pruned, never mis-driven) and drives C_LANEPLAYS. +// +// Opens NO undo block, where mintManagedLanes below opens one over the same +// parameters — the one criterion this directory's CLAUDE.md states, applied: this +// re-asserts derived state the view model recomputes from membership on every apply, +// so nothing here needs to survive as a point of its own; minting creates lanes and +// re-homes items, which no re-derivation reproduces. LaneApplyResult applyLaneOps(const TrackByGuid& handleByGuid, const std::vector& lanes); // Splits any track visible in more than one mode while carrying its own media into // fixed lanes (one managed lane per involved mode), assigns items, and records -// ownership in `model`. Wraps the mutation in one Undo block when non-empty. Returns -// true if any lane was minted (repaint hint). `proj` == nullptr means the current -// project. +// ownership in `model`. Wraps the mutation in one Undo block when non-empty — see +// applyLaneOps above for why this site opens one and that one does not — and ENDS THE +// RECLAIM SPAN first, since REAPER's blocks are a depth counter and this is reachable +// from a timer tick with a prior switch's span still open. Returns true if any lane +// was minted (repaint hint). `proj` == nullptr means the current project. bool mintManagedLanes(ViewModeModel& model, ReaProject* proj); // Recovers managed-lane ownership from durable P_LANENAME on project open — diff --git a/src/shell/view/view_park.cpp b/src/shell/view/view_park.cpp index 58f4785..a65da1b 100644 --- a/src/shell/view/view_park.cpp +++ b/src/shell/view/view_park.cpp @@ -144,7 +144,6 @@ TrackParkResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTr snap.fxKeying = prior->fxKeying; } model.storeSnapshot(guid, snap); - result.wrote = true; } if (applyFlags(tr, flags)) result.wrote = true; @@ -153,9 +152,9 @@ TrackParkResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTr bool restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, const std::vector& flags) { - applyFlags(tr, flags); + const bool wrote = applyFlags(tr, flags); model.releaseSnapshotFlags(guid); - return true; + return wrote; } std::string trackDisplayName(MediaTrack* tr) { diff --git a/src/shell/view/view_park.h b/src/shell/view/view_park.h index 5150db2..fa639f6 100644 --- a/src/shell/view/view_park.h +++ b/src/shell/view/view_park.h @@ -93,11 +93,16 @@ extern const int kViewUndoMask; // Returns whether it wrote. bool writeIfChanged(MediaTrack* tr, const char* parm, double value); -// What one parkTrack call did. `wrote` is any project write at all — a flag write -// or a record write — and feeds the apply's undo-point fold. `exposed` is true iff -// this call handed the track back visible instead of parking or adopting it; the -// caller (view.cpp) collects exposed tracks across the whole apply for the one -// batched console report. +// What one parkTrack call did. `wrote` is a REAPER write — a driven flag moved — +// and nothing else: storing the snapshot is a MODEL move, and the model reaches disk +// outside every undo block, so counting it would mint a point carrying none of it +// (ModeApplyResult, view.h). The two readings part company on exactly one path — a +// RE-PARK whose restore had already handed the flags back at park-shaped values, which +// stores a fresh snapshot and writes no flag — and the REAPER reading is the one that +// wants: nothing in the project moved, so nothing is there for a point to hold. +// `exposed` is true iff this call handed the track back visible instead of parking or +// adopting it; the caller (view.cpp) collects exposed tracks across the whole apply for +// the one batched console report. struct TrackParkResult { bool wrote = false; bool exposed = false; @@ -170,8 +175,9 @@ inline bool shouldReport(bool sameOwnerAsLast, const std::vector& l void reportExposedTracks(ReaProject* proj, const std::vector& trackNames); // Restores one returning leaf from its record and spends the record's flag half. -// Always true: the record is consumed here, so this apply changed state and is not -// repeatable, which is exactly what its one reader (the undo-point decision) asks. +// Returns whether a FLAG actually moved — the same REAPER-writes-only reading +// TrackParkResult::wrote carries, and for the same reason: spending the record is a +// model move no undo point can hold. bool restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, const std::vector& flags); From b7b93a7ac300bf5a8984de5386965af895393aea Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 16:38:11 -0400 Subject: [PATCH 07/14] One undo-block opener for the whole tree: fourteen sites route through it, and the reclaim span can no longer swallow a user's act The drain refuses a tick inside a foreign block; applyMode wakes the reclaimer before it opens the span. --- CLAUDE.md | 5 +- src/app/CMakeLists.txt | 1 + src/shell/actions/CLAUDE.md | 12 +- src/shell/actions/arrange_drop_win.cpp | 10 +- src/shell/actions/design_view_actions.cpp | 66 +++------ src/shell/actions/ingest.cpp | 59 ++++---- src/shell/actions/instrument_drop_win.cpp | 7 +- src/shell/bank_ops/CLAUDE.md | 13 +- src/shell/bank_ops/bank_ops.cpp | 13 +- src/shell/capture/CLAUDE.md | 4 + src/shell/capture/bake_land.cpp | 30 +--- src/shell/capture/capture_batch.cpp | 41 +++--- src/shell/capture/capture_orchestrator.cpp | 29 ++-- src/shell/capture/insert.cpp | 17 +-- src/shell/capture/insert.h | 2 +- src/shell/capture/render_in_place.cpp | 157 ++++++++++----------- src/shell/undo/CLAUDE.md | 53 +++++++ src/shell/undo/undo_block.cpp | 51 +++++++ src/shell/undo/undo_block.h | 42 ++++++ src/shell/view/CLAUDE.md | 72 ++++++---- src/shell/view/fx_reclaim.cpp | 14 +- src/shell/view/fx_reclaim.h | 12 +- src/shell/view/view.cpp | 7 + src/shell/view/view_lanes.cpp | 68 +++++---- tests/test_fx_reclaim.cpp | 89 ++++++++++++ 25 files changed, 561 insertions(+), 313 deletions(-) create mode 100644 src/shell/undo/CLAUDE.md create mode 100644 src/shell/undo/undo_block.cpp create mode 100644 src/shell/undo/undo_block.h diff --git a/CLAUDE.md b/CLAUDE.md index 8b4f9bc..5af3c14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use. -Per-module detail — what each file owns, its invariants — lives in the twenty-seven per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below. +Per-module detail — what each file owns, its invariants — lives in the twenty-eight per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below. ## Settled decisions @@ -84,7 +84,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on ## Architecture: the load-bearing split -`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-seven directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth. +`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-eight directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth. | Directory | Scope | |---|---| @@ -114,6 +114,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on | `src/shell/package/` | package filesystem I/O (streaming atomic read/write, exclusive-create landing), the rollback journal, and the REAPER file pickers | | `src/shell/panel/` | the docked bank-panel shell + the shared LICE draw kit | | `src/shell/persist/` | project ext-state persistence, prune filesystem I/O, usage scan | +| `src/shell/undo/` | the one REAPER undo-block opener every extension block goes through | | `src/shell/view/` | Design View mode application shell | ## Directory and namespace layout diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 217f589..b340903 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -38,6 +38,7 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/panel/draw_kit.cpp ${LICE_SRC} ${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp + ${REASAMPLER_SRC_DIR}/shell/undo/undo_block.cpp ${REASAMPLER_SRC_DIR}/shell/view/view.cpp ${REASAMPLER_SRC_DIR}/shell/view/fx_reclaim.cpp ${REASAMPLER_SRC_DIR}/shell/view/view_lanes.cpp diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index 56fc076..01bc6be 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -29,9 +29,13 @@ is owned by other directories and only skinned here. separation is load-bearing here same as everywhere else. Only the arrange-capture surface writes the `assignment_request` wire; Media-Explorer import and file-drop onto the bank panel do not, and neither affects a live instance's selection. -- **Every bank index verb wraps its mutation in a batched REAPER undo point** - (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is - one Ctrl-Z. +- **Every undo block in this directory is opened through `shell/undo`'s + `UndoBlock`**, never `Undo_BeginBlock2` directly — the ingest surfaces, the two + drop shells and the Design View membership actions alike. That is what keeps a + user's act its own correctly-labelled Ctrl-Z while a Design View mode switch's FX + reclaim span is open; the mechanism and its compile-time enforcement are + `src/shell/undo/CLAUDE.md`'s. Bank index verbs additionally stay one batched + `UNDO_STATE_MISCCFG` point apiece, so one bank operation is one Ctrl-Z. - **The prune action is the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). It halts on `blockedByTracking` and prints whichever blockers fired — the malformed ledger, @@ -39,7 +43,7 @@ is owned by other directories and only skinned here. ## Modules -- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions. +- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins; `src/shell/bank_ops/CLAUDE.md` owns the one-op-one-Ctrl-Z rule those verbs enforce. The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions. - `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body. - `drag_out_win` — the host/OS boundary of drag-out: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`. Also owns `pointerOverHostWindow`, the window-ownership test behind `ui::DropContext::pointerOffHost` (`core/ui/CLAUDE.md` owns the hand-off rule it feeds) — process identity on Windows (`WindowFromPoint` + `GetWindowThreadProcessId`), non-null `WindowFromPoint` under SWELL, whose window list is already this-process-only. Its shipped exception: a plugin bridged into `reaper_host*.exe` reports off-host though the pointer never left REAPER. - `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns) — it answers only what REAPER named, never whether the pointer is still in REAPER — and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.** diff --git a/src/shell/actions/arrange_drop_win.cpp b/src/shell/actions/arrange_drop_win.cpp index 8886413..41ee6b8 100644 --- a/src/shell/actions/arrange_drop_win.cpp +++ b/src/shell/actions/arrange_drop_win.cpp @@ -29,6 +29,7 @@ #include #include "core/capture/insert_plan.h" // computeInsertMode — the ONE InsertMedia bitfield owner +#include "shell/undo/undo_block.h" // the ONE undo-block opener #include "reaper_plugin.h" @@ -42,8 +43,6 @@ #define REAPERAPI_WANT_SetOnlyTrackSelected #define REAPERAPI_WANT_SetTrackSelected #define REAPERAPI_WANT_SnapToGrid -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler { @@ -98,7 +97,7 @@ int performArrangeDrop(MediaTrack* track, double time, // One undo block around the whole drop (every item plus the selection/cursor restore) so a // single Ctrl-Z returns the project to exactly its pre-drop state. - Undo_BeginBlock2(nullptr); + UndoBlock undo(nullptr); SetOnlyTrackSelected(track); SetEditCurPos(SnapToGrid(nullptr, time), /*moveview=*/false, /*seekplay=*/false); @@ -111,9 +110,6 @@ int performArrangeDrop(MediaTrack* track, double time, if (InsertMedia(path.c_str(), mode) != 0) ++inserted; } - // A fixed stack buffer, not std::string concatenation: the prior shape built the label with - // std::to_string + `+` between the restore below and Undo_EndBlock2, so a bad_alloc there - // would leave an unbalanced undo block open. snprintf here removes the allocation outright. char label[64]; std::snprintf(label, sizeof(label), "ReaSampler: drop %d %s onto arrange", inserted, inserted == 1 ? "capture" : "captures"); @@ -122,7 +118,7 @@ int performArrangeDrop(MediaTrack* track, double time, SetEditCurPos(priorCursor, /*moveview=*/false, /*seekplay=*/false); // extraflags -1 = UNDO_STATE_ALL, matching the insert action's own block. - Undo_EndBlock2(nullptr, label, -1); + undo.recordPoint(label, -1); return inserted; } diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index 2147866..24bb279 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -25,7 +25,7 @@ #include "shell/capture/item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) #include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key #include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint -#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — no block opens over the span +#include "shell/undo/undo_block.h" // the ONE undo-block opener #include "shell/view/view.h" // applyMode (D2 shell) #include "shell/view/view_lanes.h" // mintManagedLanes #include "shell/view/view_park.h" // kViewUndoMask @@ -41,8 +41,6 @@ #define REAPERAPI_WANT_Main_SaveProject #define REAPERAPI_WANT_Help_Set #define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler { @@ -217,37 +215,13 @@ void doActivateMode(const std::string& modeId) { // of its own — nothing it writes mints a point unaided — so a user-fired tag stays one // Ctrl-Z only because the ACTION spans it, and the label names the act the user // actually fired rather than the reapply it happened to trigger. Every one of them -// closes the block before persistViewState, which can pop a modal Save-As. Stack RAII -// so a throw between the two calls cannot leave the block open in the user's project. +// closes the block before persistViewState, which can pop a modal Save-As. // -// ENDING THE RECLAIM SPAN IS PART OF OPENING, and every action here inherits it from -// this one constructor. A mode switch fired seconds ago has left the span open; a block -// opened over it makes each close pair with the other's open, collapsing the switch -// into this tag — mechanism and full rule in src/shell/view/CLAUDE.md. -// -// `markWrote` decides whether this block mints at all: re-tagging tracks that already -// sit in that mode writes nothing to the project, and the ("", 0) discard form is real -// from a hand-fired action (DAW-measured, same doc). The membership move itself is -// never in the point either way — persistViewState runs after the close. -class ActionUndoBlock { -public: - ActionUndoBlock(std::string label, int mask) : label_(std::move(label)), mask_(mask) { - fxReclaimEndUndoSpan(); - Undo_BeginBlock2(nullptr); - } - ~ActionUndoBlock() { - if (wrote_) Undo_EndBlock2(nullptr, label_.c_str(), mask_); - else Undo_EndBlock2(nullptr, "", 0); - } - void markWrote() { wrote_ = true; } - ActionUndoBlock(const ActionUndoBlock&) = delete; - ActionUndoBlock& operator=(const ActionUndoBlock&) = delete; - -private: - std::string label_; - int mask_; - bool wrote_ = false; -}; +// The point is recorded only where the reapply actually wrote: re-tagging tracks that +// already sit in that mode writes nothing to the project, and the ("", 0) discard form +// UndoBlock closes on by default is real from a hand-fired action (DAW-measured, +// src/shell/view/CLAUDE.md). The membership move itself is never in the point either +// way — persistViewState runs after the close. // tag() replaces any prior single-mode membership (a leaf lives in one mode; the // cross-mode case is show-both). @@ -256,9 +230,10 @@ void doTag(const std::string& modeId) { if (guids.empty()) return; // nothing selected — no act, no undo point (cf. doMoveItems) { - ActionUndoBlock undo("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask); + UndoBlock undo(nullptr); for (const std::string& g : guids) g_session->view().membership().tag(g, modeId); - if (reapplyActiveMode()) undo.markWrote(); + if (reapplyActiveMode()) + undo.recordPoint("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask); } persistViewState(PersistScope::Full); } @@ -270,9 +245,10 @@ void doUntag() { if (guids.empty()) return; { - ActionUndoBlock undo("ReaSampler: untag selected tracks", kViewUndoMask); + UndoBlock undo(nullptr); for (const std::string& g : guids) g_session->view().membership().untag(g); - if (reapplyActiveMode()) undo.markWrote(); + if (reapplyActiveMode()) + undo.recordPoint("ReaSampler: untag selected tracks", kViewUndoMask); } persistViewState(PersistScope::Full); } @@ -284,10 +260,11 @@ void doShowBoth() { if (guids.empty()) return; { - ActionUndoBlock undo("ReaSampler: toggle show-both on selected tracks", kViewUndoMask); + UndoBlock undo(nullptr); MembershipIndex& m = g_session->view().membership(); for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g)); - if (reapplyActiveMode()) undo.markWrote(); + if (reapplyActiveMode()) + undo.recordPoint("ReaSampler: toggle show-both on selected tracks", kViewUndoMask); } persistViewState(PersistScope::Full); } @@ -307,10 +284,7 @@ void doMoveItems(const std::string& targetMode) { MembershipIndex& membership = g_session->view().membership(); { - ActionUndoBlock undo(targetMode.empty() - ? std::string("ReaSampler: untag selected items") - : "ReaSampler: move selected items -> " + targetMode, - -1); + UndoBlock undo(nullptr); for (const ItemRetagOp& op : ops) { if (op.untag) membership.untag(op.guid); else membership.tag(op.guid, op.modeId); @@ -321,7 +295,11 @@ void doMoveItems(const std::string& targetMode) { // no flag leaves nothing for a point to hold. const bool minted = mintManagedLanes(g_session->view(), nullptr); const bool reapplied = reapplyActiveMode(); - if (minted || reapplied) undo.markWrote(); + if (minted || reapplied) + undo.recordPoint(targetMode.empty() + ? std::string("ReaSampler: untag selected items") + : "ReaSampler: move selected items -> " + targetMode, + -1); } persistViewState(PersistScope::Full); diff --git a/src/shell/actions/ingest.cpp b/src/shell/actions/ingest.cpp index 65ce842..538494d 100644 --- a/src/shell/actions/ingest.cpp +++ b/src/shell/actions/ingest.cpp @@ -22,6 +22,7 @@ #include "core/wire/instrument_drop.h" // pure buildInstrumentDropPreset (.vstpreset image for a sampleId) #include "shell/actions/instrument_drop_win.h" // shell loadInstrumentOntoTrack (FX add+apply, no own undo block) #include "shell/persist/session.h" // ReaSamplerSession +#include "shell/undo/undo_block.h" // the ONE undo-block opener #include "core/capture/wav_codec.h" // parseWavLayout (32f fast-path validator), buildFloat32Wav, hashWavContent @@ -36,8 +37,6 @@ #define REAPERAPI_WANT_GetMediaSourceNumChannels #define REAPERAPI_WANT_GetMediaSourceSampleRate #define REAPERAPI_WANT_GetMediaSourceLength -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #define REAPERAPI_WANT_GetSelectedTrack #include "reaper_plugin_functions.h" @@ -362,14 +361,14 @@ void doImportFromMediaExplorer() { // so any open VST3 browser instances refresh to show the new sound. bool persisted = true; // true when nothing needed persisting (dedup) if (r.added) { - Undo_BeginBlock2(nullptr); - g_session->bumpBankGeneration(); - persisted = g_session->saveToActiveProject(); - if (persisted) - Undo_EndBlock2(nullptr, "ReaSampler: import Media Explorer file into bank", - UNDO_STATE_MISCCFG); - else - Undo_EndBlock2(nullptr, "", 0); + { + UndoBlock undo(nullptr); + g_session->bumpBankGeneration(); + persisted = g_session->saveToActiveProject(); + if (persisted) + undo.recordPoint("ReaSampler: import Media Explorer file into bank", + UNDO_STATE_MISCCFG); + } bankPanelRefresh(); } ShowConsoleMsg(("ReaSampler ingest: " + r.message + @@ -386,20 +385,21 @@ void doImportFromMediaExplorer() { // Persist happens INSIDE the block and BEFORE the FX add so the new instance's // setState -> reloadInstrument sees the just-persisted sample. - Undo_BeginBlock2(nullptr); bool persisted = true; // true when nothing needed persisting (dedup) - if (r.added) { - g_session->bumpBankGeneration(); - persisted = g_session->saveToActiveProject(); + bool placed = false; + { + UndoBlock undo(nullptr); + if (r.added) { + g_session->bumpBankGeneration(); + persisted = g_session->saveToActiveProject(); + } + placed = loadInstrumentOntoTrack(target, preset); + // Anything else — the FX add/inject failed (already rolled back, no orphan), or + // the project was unsaved (persist no-op) — records no point at all. + if (placed && persisted) + undo.recordPoint("ReaSampler: import from Media Explorer into selected track", + UNDO_STATE_MISCCFG); } - const bool placed = loadInstrumentOntoTrack(target, preset); - if (placed && persisted) - Undo_EndBlock2(nullptr, "ReaSampler: import from Media Explorer into selected track", - UNDO_STATE_MISCCFG); - else - // Either the FX add/inject failed (already rolled back, no orphan) or the - // project was unsaved (persist no-op): discard so no empty point is recorded. - Undo_EndBlock2(nullptr, "", 0); bankPanelRefresh(); if (placed) @@ -450,13 +450,14 @@ void ingestDroppedFiles(const std::vector& absolutePaths) { // One undo point for the whole drop, opened only if a NEW index entry was created. if (importedNew > 0) { - Undo_BeginBlock2(nullptr); - g_session->bumpBankGeneration(); - const bool persisted = g_session->saveToActiveProject(); - if (persisted) - Undo_EndBlock2(nullptr, "ReaSampler: import dropped file(s)", UNDO_STATE_MISCCFG); - else - Undo_EndBlock2(nullptr, "", 0); + bool persisted = false; + { + UndoBlock undo(nullptr); + g_session->bumpBankGeneration(); + persisted = g_session->saveToActiveProject(); + if (persisted) + undo.recordPoint("ReaSampler: import dropped file(s)", UNDO_STATE_MISCCFG); + } bankPanelRefresh(); const std::string msg = "ReaSampler ingest: imported " + std::to_string(importedTotal) + diff --git a/src/shell/actions/instrument_drop_win.cpp b/src/shell/actions/instrument_drop_win.cpp index d385c49..732cf9d 100644 --- a/src/shell/actions/instrument_drop_win.cpp +++ b/src/shell/actions/instrument_drop_win.cpp @@ -13,6 +13,7 @@ #include "core/version/app_version.h" // vstPluginName() — the CHANNEL-correct FX name (stable/beta pairing) #include "core/wire/instrument_drop.h" // classifyReaperSurface — the PURE, unit-tested classifier +#include "shell/undo/undo_block.h" // the ONE undo-block opener #include "reaper_plugin.h" @@ -22,8 +23,6 @@ #define REAPERAPI_WANT_TrackFX_Delete #define REAPERAPI_WANT_TrackFX_GetCount #define REAPERAPI_WANT_TrackFX_SetPreset -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler { @@ -138,9 +137,9 @@ bool loadInstrumentOntoTrack(MediaTrack* track, const std::vector& bool performInstrumentDrop(MediaTrack* track, const std::vector& presetBytes) { if (!track || presetBytes.empty()) return false; - Undo_BeginBlock2(nullptr); + UndoBlock undo(nullptr); const bool ok = loadInstrumentOntoTrack(track, presetBytes); - Undo_EndBlock2(nullptr, "ReaSampler: drop capture onto FX chain", -1); + undo.recordPoint("ReaSampler: drop capture onto FX chain", -1); return ok; } diff --git a/src/shell/bank_ops/CLAUDE.md b/src/shell/bank_ops/CLAUDE.md index 0e3abc5..219229f 100644 --- a/src/shell/bank_ops/CLAUDE.md +++ b/src/shell/bank_ops/CLAUDE.md @@ -26,13 +26,18 @@ calls into that model, it does not reimplement its rules. - **Movement is index-only.** Moving/copying a sample between banks removes/adds the index entry only; the underlying file never moves on disk. No bank operation writes, moves, or deletes a file. -- **One bank operation is one Ctrl-Z.** Every bank index verb wraps its mutation in - a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) - via `persistBankOp` — this is enforced in this directory, not left to callers. +- **One bank operation is one Ctrl-Z, whatever else is in flight.** Every bank index + verb wraps its mutation in a batched REAPER undo point (`UNDO_STATE_MISCCFG`) via + `persistBankOp` — enforced in this directory, not left to callers. The block is + opened through `shell/undo`'s `UndoBlock`, which is what makes the claim hold while + a Design View mode switch's FX reclaim span is open: without it the verb's own + close landed at depth 2->1, minted nothing, and the rename or delete was absorbed + into the mode switch, un-undoable on its own. Reason and mechanism: + `src/shell/undo/CLAUDE.md`. ## Modules -- `bank_ops` (`shell/bank_ops`) — the promptless bank-mutation verb seam (Q-W6 lift out of `panel_bank_ops`): `bankOpCreate`/`Rename`/`Delete`/`Evacuate`/`Activate`/`Transfer`/`Remove` + `persistBankOp` (the undo-batched ext-state persist), each taking a `ReaSamplerSession&` and returning whether the model accepted the mutation — no prompts, no message boxes, no panel-state reads. `shell/panel/panel_bank_ops` (menu/prompt UX) and `shell/actions/bank_actions` (bindable-action UX) both consume these as thin skins, so the mutation logic has exactly one home. +- `bank_ops` (`shell/bank_ops`) — the promptless bank-mutation verb seam (Q-W6 lift out of `panel_bank_ops`): `bankOpCreate`/`Rename`/`Delete`/`Evacuate`/`Activate`/`Transfer`/`Remove` + `persistBankOp` (the `UndoBlock`-batched ext-state persist), each taking a `ReaSamplerSession&` and returning whether the model accepted the mutation — no prompts, no message boxes, no panel-state reads. `shell/panel/panel_bank_ops` (menu/prompt UX) and `shell/actions/bank_actions` (bindable-action UX) both consume these as thin skins, so the mutation logic has exactly one home. ## Gotchas diff --git a/src/shell/bank_ops/bank_ops.cpp b/src/shell/bank_ops/bank_ops.cpp index 134715e..5b4989b 100644 --- a/src/shell/bank_ops/bank_ops.cpp +++ b/src/shell/bank_ops/bank_ops.cpp @@ -14,12 +14,11 @@ #include "core/model/bank_book.h" // BankBook / TransferResult / RemoveScope #include "shell/persist/session.h" // ReaSamplerSession — the session the verbs mutate +#include "shell/undo/undo_block.h" // the ONE undo-block opener #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 { @@ -50,13 +49,11 @@ std::string mintBankId() { // design (mirrors capture, not Design-View) — deliberately no Save-As prompt. void persistBankOp(ReaSamplerSession& session, const char* label, bool bumpGeneration) { - Undo_BeginBlock2(nullptr); + UndoBlock undo(nullptr); 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 + // No ext-state write -> record no point; the block's default discard form is what + // keeps an unsaved project from collecting no-effect undo entries. + if (session.saveToActiveProject()) undo.recordPoint(label, UNDO_STATE_MISCCFG); } // --- Promptless inner bank verbs (one home) ------------------------------------ diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index 064b826..705eecd 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -20,6 +20,10 @@ relative-paths-only) and the load-bearing capture/placement separation — see r `CLAUDE.md` §Precision invariants and §The load-bearing principle. Shell-specific detail not covered there: +- **Every undo block here is opened through `shell/undo`'s `UndoBlock`**, never + `Undo_BeginBlock2` directly — `insert`, `capture_orchestrator`, `capture_batch`, + `bake_land` and `render_in_place` alike. Rule and enforcement: + `src/shell/undo/CLAUDE.md`. - **The item scope's render source is window-dependent.** `ResolveScopeSource` is where that is decided — it measures the selected items' extent against the resolved range (`core/capture/render_window`) and hands the answer to diff --git a/src/shell/capture/bake_land.cpp b/src/shell/capture/bake_land.cpp index 6704755..add8848 100644 --- a/src/shell/capture/bake_land.cpp +++ b/src/shell/capture/bake_land.cpp @@ -1,7 +1,8 @@ // bake_land.cpp — see bake_land.h. main.cpp owns the API pointers; this TU gets them // extern. REAPER symbols used here (EnumProjects, EnumProjExtState, GetProjExtState, -// SetProjExtState, ShowConsoleMsg, Undo_BeginBlock2/EndBlock2) are verified against -// vendor/reaper-sdk/sdk/reaper_plugin_functions.h. Landing one request is bake_landing's. +// SetProjExtState, ShowConsoleMsg) are verified against +// vendor/reaper-sdk/sdk/reaper_plugin_functions.h. Landing one request is +// bake_landing's; the undo block is shell/undo's. #include "shell/capture/bake_land.h" @@ -19,6 +20,7 @@ #include "shell/capture/bake_landing.h" #include "shell/panel/panel_input.h" // bankPanelRefresh #include "shell/persist/session.h" +#include "shell/undo/undo_block.h" // the ONE undo-block opener #include "reaper_plugin.h" // UNDO_STATE_MISCCFG @@ -28,8 +30,6 @@ #define REAPERAPI_WANT_GetProjExtState #define REAPERAPI_WANT_SetProjExtState #define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler::capture { @@ -107,26 +107,6 @@ struct ScannedKey { wire::BakeKeyOutcome report; }; -// Undo_BeginBlock2/EndBlock2 as stack RAII, the FxBypassGuard discipline: a landing reads a -// whole WAV and hashes it, so a bad_alloc between the two calls would otherwise leave an -// open undo block in the user's project. Closing with an empty description records no undo -// point, which is what every path that landed nothing wants anyway. -class UndoBlock { -public: - UndoBlock() { Undo_BeginBlock2(nullptr); } - ~UndoBlock() { Undo_EndBlock2(nullptr, description_.c_str(), flags_); } - void recordPoint(std::string description, int flags) { - description_ = std::move(description); - flags_ = flags; - } - UndoBlock(const UndoBlock&) = delete; - UndoBlock& operator=(const UndoBlock&) = delete; - -private: - std::string description_; - int flags_ = 0; -}; - // One entry's write-back, and the proof of whether it took. Guarded whole: it encodes into // a fresh string and readKey grows a buffer toward 16 MB, and a throw escaping here would // skip the ShowConsoleMsg for EVERY key — the silent console this action exists to make @@ -206,7 +186,7 @@ void RunResampleBake(ReaSamplerSession& session) { // the answers already buffered — the exact no-answer-with-a-silent-console this action // exists to make impossible. UndoBlock is inside the try, so unwinding still closes it. try { - UndoBlock undo; + UndoBlock undo(nullptr); for (const OpenProject& open : openProjects()) { ++tally.tabsScanned; const bool activeTab = static_cast(open.proj) == active; diff --git a/src/shell/capture/capture_batch.cpp b/src/shell/capture/capture_batch.cpp index 97f79fe..0e5ca41 100644 --- a/src/shell/capture/capture_batch.cpp +++ b/src/shell/capture/capture_batch.cpp @@ -25,6 +25,7 @@ #include "shell/capture/render_bounds_gate.h" // refusedRenderFolder #include "shell/capture/scope_resolve.h" // ResolvedSource #include "shell/capture/track_guid.h" // guidString +#include "shell/undo/undo_block.h" // the ONE undo-block opener #include "reaper_plugin.h" // UNDO_STATE_MISCCFG @@ -46,8 +47,6 @@ #define REAPERAPI_WANT_SetTrackSelected #define REAPERAPI_WANT_SetOnlyTrackSelected #define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler::capture { @@ -493,26 +492,26 @@ void RunRecaptureFromSource(ReaSamplerSession& session) updated.provenance = prov; // One batched undo point around the in-place mutation; index-only ext-state, - // nothing placed on the timeline. - Undo_BeginBlock2(nullptr); - // Before the index outcome is even known: the render above already wrote the file, - // and makeUniqueTag guarantees a NEW path, so this is a second record carrying the - // same sampleId as the original. The superseded file becomes an orphan for prune. - session.recordCreated(updated, tracking::OriginKind::Recapture); - const bool changed = session.book().updateSampleInPlace(sampleId, updated); - if (changed) + // nothing placed on the timeline. A plan that mutated nothing records no point at + // all — the block's default discard form. { - // Regenerating the same id's audio is exactly why instances need the generation - // bump — they'd otherwise keep playing stale audio until reload. Bumped inside - // the undo block so undo rolls back the generation with the rest of the blob. - session.bumpBankGeneration(); - const bool persisted = session.saveToActiveProject(); // book + ledger + generation + MarkProjectDirty - Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "", - persisted ? UNDO_STATE_MISCCFG : 0); - } - else - { - Undo_EndBlock2(nullptr, "", 0); // nothing mutated -> discard the empty point + UndoBlock undo(nullptr); + // Before the index outcome is even known: the render above already wrote the + // file, and makeUniqueTag guarantees a NEW path, so this is a second record + // carrying the same sampleId as the original. The superseded file becomes an + // orphan for prune. + session.recordCreated(updated, tracking::OriginKind::Recapture); + if (session.book().updateSampleInPlace(sampleId, updated)) + { + // Regenerating the same id's audio is exactly why instances need the + // generation bump — they'd otherwise keep playing stale audio until reload. + // Bumped inside the undo block so undo rolls back the generation with the + // rest of the blob. + session.bumpBankGeneration(); + // book + ledger + generation + MarkProjectDirty + if (session.saveToActiveProject()) + undo.recordPoint("ReaSampler: re-capture from source", UNDO_STATE_MISCCFG); + } } bankPanelRefresh(); // reflect the regenerated file in the docked grid diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index d0ef312..adaa30d 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -18,6 +18,7 @@ #include "shell/capture/realtime_lifecycle.h" // the in-flight realtime state #include "shell/capture/render_selection.h" // RenderTrackSelection #include "shell/capture/render_isolation.h" // UpstreamIsolation +#include "shell/undo/undo_block.h" // the ONE undo-block opener #include "reaper_plugin.h" // UNDO_STATE_MISCCFG @@ -28,8 +29,6 @@ #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_SetMediaTrackInfo_Value #define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler::capture { @@ -369,20 +368,22 @@ void RunCaptureItemAssign(ReaSamplerSession& session) { // Reuse the Item-scope def from the capture table (index 0) — same range logic, same // FX-scope neutralize, same bank/persist landing as the plain "capture item" action. - Undo_BeginBlock2(nullptr); - - const std::string sampleId = RunCapture(session, captureActionTable()[0]); - if (sampleId.empty()) { - // Capture failed or no-op'd — RunCapture already reported. Discard the empty point. - Undo_EndBlock2(nullptr, "", 0); - return; - } + UndoBlock undo(nullptr); - // Assign inside the same block so undo clears both keys together. - ingestAssignActiveInstance(session.book().activeBankId(), sampleId); - Undo_EndBlock2(nullptr, "ReaSampler: capture + assign to active instance", - UNDO_STATE_MISCCFG); + const std::string sampleId = RunCapture(session, captureActionTable()[0]); + if (sampleId.empty()) + { + // Capture failed or no-op'd — RunCapture already reported. The block closes + // on its default discard form, so the empty point is never recorded. + return; + } + + // Assign inside the same block so undo clears both keys together. + ingestAssignActiveInstance(session.book().activeBankId(), sampleId); + undo.recordPoint("ReaSampler: capture + assign to active instance", + UNDO_STATE_MISCCFG); + } bankPanelRefresh(); ShowConsoleMsg("ReaSampler ingest: captured into the bank and assigned to the active " diff --git a/src/shell/capture/insert.cpp b/src/shell/capture/insert.cpp index 412197f..3e0411d 100644 --- a/src/shell/capture/insert.cpp +++ b/src/shell/capture/insert.cpp @@ -33,6 +33,7 @@ #include "shell/panel/panel_bank_ops.h" // bankPanelSelectedSampleIds / SourceBankId #include "core/capture/capture_paths.h" #include "shell/persist/session.h" +#include "shell/undo/undo_block.h" #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedTracks @@ -44,8 +45,6 @@ #define REAPERAPI_WANT_SetOnlyTrackSelected #define REAPERAPI_WANT_SetTrackSelected #define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler { @@ -139,9 +138,8 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) const double cursorPos = GetCursorPosition(); // One undo block around the whole placement (all tracks + selection/cursor - // restore) so a single undo removes every item and restores prior state. Always - // balanced — opened before the first insert, closed after the restore. - Undo_BeginBlock2(nullptr); + // restore) so a single undo removes every item and restores prior state. + UndoBlock undo(nullptr); // Insert onto each selected track at the same cursor position (assumption B): per // track, isolate it as the only selection (A + E), reset the cursor (C is @@ -159,11 +157,10 @@ InsertResult runInsert(ReaSamplerSession* session, const InsertRequest& request) // Label reflects count + conform choice for a clear undo history. extraflags -1 = // UNDO_STATE_ALL (tracks, items, envelope points, project state). - const std::string label = - "ReaSampler: insert on " + std::to_string(result.inserted) + - (result.inserted == 1 ? " track" : " tracks") + - (request.options.conform == TempoConform::None ? "" : " (conform)"); - Undo_EndBlock2(nullptr, label.c_str(), -1); + undo.recordPoint("ReaSampler: insert on " + std::to_string(result.inserted) + + (result.inserted == 1 ? " track" : " tracks") + + (request.options.conform == TempoConform::None ? "" : " (conform)"), + -1); result.status = InsertStatus::Ok; return result; diff --git a/src/shell/capture/insert.h b/src/shell/capture/insert.h index 1255e80..782aead 100644 --- a/src/shell/capture/insert.h +++ b/src/shell/capture/insert.h @@ -44,7 +44,7 @@ struct InsertResult { // currently-selected track set, then inserts the sample onto EACH selected track // at the SAME edit-cursor position. Snapshot/restore ensures the user's track // selection and cursor position are unchanged after the action. The whole operation -// is wrapped in a single Undo_BeginBlock2 / Undo_EndBlock2. +// is wrapped in a single UndoBlock. // // No-op cases (with console messages): // - No track selected: prints "select a track first." diff --git a/src/shell/capture/render_in_place.cpp b/src/shell/capture/render_in_place.cpp index cab8935..713dcfb 100644 --- a/src/shell/capture/render_in_place.cpp +++ b/src/shell/capture/render_in_place.cpp @@ -21,7 +21,7 @@ #include "shell/capture/track_guid.h" // guidString #include "shell/panel/panel_input.h" // bankPanelTailSetting #include "shell/persist/session.h" -#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan +#include "shell/undo/undo_block.h" // the ONE undo-block opener #include "shell/view/view.h" // applyMode #include "shell/view/view_lanes.h" // mintManagedLanes @@ -43,8 +43,6 @@ #define REAPERAPI_WANT_SetOnlyTrackSelected #define REAPERAPI_WANT_ShowConsoleMsg #define REAPERAPI_WANT_TrackList_AdjustWindows -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #include "reaper_plugin_functions.h" namespace reasampler::capture { @@ -134,87 +132,88 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) { // visible — never to choose a tag. Both tags below are absolute. const std::string activeMode = session.view().activeModeId(); - // A mode switch fired seconds ago has left the FX reclaimer's undo span open, and - // no block may open over it — mechanism in src/shell/view/CLAUDE.md. Ending it here - // mints the switch's point, whole, before this block exists; idempotent otherwise. - fxReclaimEndUndoSpan(); + // Both read after the block closes, so both outlive the scope below. + bool placed = false; + std::string freshName; - Undo_BeginBlock2(nullptr); + // Scoped so the persist below lands OUTSIDE the block — see the note at the close. + { + UndoBlock undo(nullptr); - // flags = 0, never 1: flags&1 adds default envelopes/FX, and a default chain - // would process a render that already carries the source's FX a second time. - InsertTrackInProject(proj, place.insertIndex, /*flags=*/0); - MediaTrack* fresh = GetTrack(proj, place.insertIndex); - if (!fresh) { - // InsertTrackInProject already mutated the project by this point, so the - // "no ext-state write -> discard" idiom does not apply here — a discard would - // leave the orphaned track un-undoable. - Undo_EndBlock2(nullptr, "ReaSampler: render in place (failed to create result track)", - -1); - refuse("could not create the result track; the render landed at " + - res.absolutePath + " but was not placed."); - return; - } - - // Both writes or none — one alone lands the new track at the wrong nesting level, - // which is audible in both directions (see siblingPlacement). - if (place.precedingIndex >= 0) { - if (MediaTrack* preceding = GetTrack(proj, place.precedingIndex)) - SetMediaTrackInfo_Value(preceding, "I_FOLDERDEPTH", - static_cast(place.precedingDepth)); - } - SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH", - static_cast(place.newDepth)); - - // GetTrackColor returns the colour already OR'd with 0x1000000 and 0 for "no - // colour set", which I_CUSTOMCOLOR reads as unused — so one line clones a colour - // and the absence of one, with no branch. - SetMediaTrackInfo_Value(fresh, "I_CUSTOMCOLOR", - static_cast(GetTrackColor(source))); - const std::string freshName = captureTrackName(trackName(source)); - setTrackName(fresh, freshName); - - // After every attribute write, per the SDK header's manual-panel-update caveat. - TrackList_AdjustWindows(false); - - // Unsnapped and unrounded, deliberately: this placement IS the null test performed - // automatically, so snapping it to the grid would move the audio off the position - // it was rendered from. InsertOptions{} defaults give native length and no conform. - const double cursorPos = GetCursorPosition(); - SetOnlyTrackSelected(fresh); - SetEditCurPos(src.startSeconds, false, false); - // InsertMedia's int return isn't SDK-documented; treated conservatively as - // 0 = failure, matching performArrangeDrop — an empty result track would - // otherwise be a silent no-op, which is exactly what this verb must not produce. - const bool placed = - InsertMedia(res.absolutePath.c_str(), computeInsertMode(InsertOptions{})) != 0; - SetEditCurPos(cursorPos, false, false); - // The new track is left selected, alone — in the headline case the source is being - // parked out of sight in the same gesture, so restoring the selection would leave - // the user selecting an invisible track. - - // Absolute, not mode-following: the source parks on the bench, the result is an - // Arrange member whatever mode was active. Explicit records rather than untag(), - // because the record is what the panel's auto-tag detector defers to. - MembershipIndex& membership = session.view().membership(); - membership.tag(guidString(source), kDesignModeId); - membership.tag(guidString(fresh), kArrangeModeId); - - // The track is brand new, so its items are exactly the ones just placed. An - // untagged item would be handed to the detector, which tags to the active mode. - const int itemCount = CountTrackMediaItems(fresh); - for (int i = 0; i < itemCount; ++i) { - if (MediaItem* it = GetTrackMediaItem(fresh, i)) { - const std::string ig = itemGuid(it); - if (!ig.empty()) membership.tag(ig, kArrangeModeId); + // flags = 0, never 1: flags&1 adds default envelopes/FX, and a default chain + // would process a render that already carries the source's FX a second time. + InsertTrackInProject(proj, place.insertIndex, /*flags=*/0); + MediaTrack* fresh = GetTrack(proj, place.insertIndex); + if (!fresh) { + // InsertTrackInProject already mutated the project by this point, so the + // "no ext-state write -> discard" idiom does not apply here — a discard would + // leave the orphaned track un-undoable. + undo.recordPoint("ReaSampler: render in place (failed to create result track)", -1); + refuse("could not create the result track; the render landed at " + + res.absolutePath + " but was not placed."); + return; } + + // Both writes or none — one alone lands the new track at the wrong nesting level, + // which is audible in both directions (see siblingPlacement). + if (place.precedingIndex >= 0) { + if (MediaTrack* preceding = GetTrack(proj, place.precedingIndex)) + SetMediaTrackInfo_Value(preceding, "I_FOLDERDEPTH", + static_cast(place.precedingDepth)); + } + SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH", + static_cast(place.newDepth)); + + // GetTrackColor returns the colour already OR'd with 0x1000000 and 0 for "no + // colour set", which I_CUSTOMCOLOR reads as unused — so one line clones a colour + // and the absence of one, with no branch. + SetMediaTrackInfo_Value(fresh, "I_CUSTOMCOLOR", + static_cast(GetTrackColor(source))); + freshName = captureTrackName(trackName(source)); + setTrackName(fresh, freshName); + + // After every attribute write, per the SDK header's manual-panel-update caveat. + TrackList_AdjustWindows(false); + + // Unsnapped and unrounded, deliberately: this placement IS the null test performed + // automatically, so snapping it to the grid would move the audio off the position + // it was rendered from. InsertOptions{} defaults give native length and no conform. + const double cursorPos = GetCursorPosition(); + SetOnlyTrackSelected(fresh); + SetEditCurPos(src.startSeconds, false, false); + // InsertMedia's int return isn't SDK-documented; treated conservatively as + // 0 = failure, matching performArrangeDrop — an empty result track would + // otherwise be a silent no-op, which is exactly what this verb must not produce. + placed = + InsertMedia(res.absolutePath.c_str(), computeInsertMode(InsertOptions{})) != 0; + SetEditCurPos(cursorPos, false, false); + // The new track is left selected, alone — in the headline case the source is being + // parked out of sight in the same gesture, so restoring the selection would leave + // the user selecting an invisible track. + + // Absolute, not mode-following: the source parks on the bench, the result is an + // Arrange member whatever mode was active. Explicit records rather than untag(), + // because the record is what the panel's auto-tag detector defers to. + MembershipIndex& membership = session.view().membership(); + membership.tag(guidString(source), kDesignModeId); + membership.tag(guidString(fresh), kArrangeModeId); + + // The track is brand new, so its items are exactly the ones just placed. An + // untagged item would be handed to the detector, which tags to the active mode. + const int itemCount = CountTrackMediaItems(fresh); + for (int i = 0; i < itemCount; ++i) { + if (MediaItem* it = GetTrackMediaItem(fresh, i)) { + const std::string ig = itemGuid(it); + if (!ig.empty()) membership.tag(ig, kArrangeModeId); + } + } + + mintManagedLanes(session.view(), nullptr); + applyMode(session.view(), activeMode, nullptr); // a reapply, never a switch + + undo.recordPoint("ReaSampler: render selected track to a new track", -1); } - mintManagedLanes(session.view(), nullptr); - applyMode(session.view(), activeMode, nullptr); // a reapply, never a switch - - Undo_EndBlock2(nullptr, "ReaSampler: render selected track to a new track", -1); - // Persist outside the block. The offline render's own save gate already forced a // saved project, so the Save-As-guarded persist the Design View actions need // cannot have anything to prompt for here. diff --git a/src/shell/undo/CLAUDE.md b/src/shell/undo/CLAUDE.md new file mode 100644 index 0000000..427c8c5 --- /dev/null +++ b/src/shell/undo/CLAUDE.md @@ -0,0 +1,53 @@ +# src/shell/undo — the one REAPER undo-block opener + +## Scope + +One type, `UndoBlock`, and one query, `undoBlockOpen()`. Every extension-owned undo +block in the tree is opened through this type; nothing here decides what a point is +called or whether one is worth minting — each call site still owns its own label, +mask, and discard-vs-mint condition. The FX reclaim span is NOT this directory's: +`shell/view/fx_reclaim` owns it, and this directory only guarantees no block ever +nests over it. + +## Invariants + +- **`Undo_BeginBlock2` is not callable outside this directory, and that is enforced + by the compiler, not by review.** Only `undo_block.cpp` and `shell/view`'s + `fx_reclaim.cpp` (which owns the span's own begin/end) define + `REAPERAPI_WANT_Undo_BeginBlock2` / `..._Undo_EndBlock2`. Every other TU compiles + under `REAPERAPI_MINIMAL` with no header in its include graph pulling the SDK in + unguarded, so a raw `Undo_BeginBlock2` there is an undeclared identifier. Adding + the WANT define back to a third TU is the one way to defeat this — do not. +- **Ending the reclaim span is part of opening, and ONLY the outermost block does + it.** At depth 0 the span is the only block that can be open, so ending it mints + the pending mode switch's point whole before this block exists. At depth > 0 an + enclosing `UndoBlock` already ended it, and a close from the inner one would land + at depth 2->1 — minting nothing and hijacking the enclosing block's label. That + is the same criterion `applyMode`'s reapply is gated on; it lives in the + constructor so no call site has to make the judgment. Full reasoning for the rule + itself: `src/shell/view/CLAUDE.md`. +- **Closing is a destructor.** No throw between the open and the label decision can + leave a block open in the user's project, which is why several call sites can + allocate freely between the two. +- **No label recorded means no point.** `UndoBlock` closes on `("", 0)` unless + `recordPoint` was called — the discard form, real from a hand-fired action + (DAW-measured; `src/shell/view/CLAUDE.md`). A verb that mutated nothing therefore + costs no undo entry simply by saying nothing. + +## Modules + +- `undo_block` — `UndoBlock` (RAII open/close plus `recordPoint`) and + `undoBlockOpen()`. The latter is read by `fxReclaimTick`, which refuses a tick + outright while any block is open: the drain's lazy `openSpan` would otherwise + begin at depth 2, and the enclosing block's close would then mint under the span's + label and destroy the user's own point. The refusal consumes nothing — no wakeup, + no queue, no project pin — so the first tick after the block closes drains as if + this one had never been due. + +## Gotchas + +- The depth counter deliberately does NOT count the reclaim span. The span is the + thing this stands off, not a peer. +- The counter drops AFTER `Undo_EndBlock2`, not before: the close that reaches depth + 0 mints a point, and anything re-entered through that must still read a block as + open rather than let the drain open a span underneath it. diff --git a/src/shell/undo/undo_block.cpp b/src/shell/undo/undo_block.cpp new file mode 100644 index 0000000..bf3922d --- /dev/null +++ b/src/shell/undo/undo_block.cpp @@ -0,0 +1,51 @@ +// See undo_block.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). + +#include "shell/undo/undo_block.h" + +#include + +#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — the span this must not nest over + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// Extension-owned nesting depth only — REAPER's own counter is not readable, and the +// reclaim span is deliberately NOT counted here (it is the thing this stands off). +int g_depth = 0; + +} // namespace + +bool undoBlockOpen() { return g_depth > 0; } + +UndoBlock::UndoBlock(ReaProject* proj) : project_(proj) { + // ONLY the outermost block ends the span, which mechanizes the whole nesting rule. + // At depth 0 the span is the only block that can be open, so ending it mints the + // switch's point whole, at depth 0, before this block exists. At depth > 0 the + // enclosing UndoBlock already did it, and a close from here would land at depth + // 2->1 — minting nothing and hijacking the enclosing block's label instead. That is + // the same criterion applyMode's reapply is gated on. + if (g_depth++ == 0) fxReclaimEndUndoSpan(); + Undo_BeginBlock2(proj); +} + +UndoBlock::~UndoBlock() { + // Depth drops AFTER the SDK call, not before: the close that reaches depth 0 mints a + // point, and anything re-entered through that must still read a block as open rather + // than let the drain open a span underneath it. + Undo_EndBlock2(project_, label_.c_str(), mask_); + --g_depth; +} + +void UndoBlock::recordPoint(std::string label, int mask) { + label_ = std::move(label); + mask_ = mask; +} + +} // namespace reasampler diff --git a/src/shell/undo/undo_block.h b/src/shell/undo/undo_block.h new file mode 100644 index 0000000..7e89723 --- /dev/null +++ b/src/shell/undo/undo_block.h @@ -0,0 +1,42 @@ +#pragma once +// The ONE way an extension undo block is opened. Ending the FX reclaim span is part of +// opening, so no block can ever nest over it, and the close is a destructor, so no throw +// can leave one open in the user's project. +// +// Enforced by the SDK's own opt-in: only this TU and fx_reclaim.cpp (which owns the span +// itself) define REAPERAPI_WANT_Undo_BeginBlock2, so `Undo_BeginBlock2` does not even +// name a declared symbol anywhere else in the tree. + +#include + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class ReaProject; + +namespace reasampler { + +class UndoBlock { +public: + // `proj` is nullptr for the active project, matching Undo_BeginBlock2's own contract + // (reaper_plugin_functions.h:7753). + explicit UndoBlock(ReaProject* proj); + ~UndoBlock(); + + // What the close names the point. Never called, the block closes on the discard form + // (`""`, 0) — real from a hand-fired action, a coalesce from a timer (see UndoSpan), + // and what every site that ended up writing nothing already wanted. + void recordPoint(std::string label, int mask); + + UndoBlock(const UndoBlock&) = delete; + UndoBlock& operator=(const UndoBlock&) = delete; + +private: + ReaProject* project_ = nullptr; + std::string label_; + int mask_ = 0; +}; + +// True while any UndoBlock is alive, at any nesting depth. The FX reclaim drain reads it +// to stand off a tick it would otherwise open its span inside of — see fxReclaimTick. +bool undoBlockOpen(); + +} // namespace reasampler diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 2c5abdb..1840215 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -122,9 +122,16 @@ decide membership or mode rules. FX write, and the only reason the span exists. A TRANSPORT deferral is not bounded (it lasts as long as the playback pass), so it ends the span and the parking that follows mints its own point (`spanEndsAfter`). -- **NOTHING MAY OPEN AN UNDO BLOCK WHILE THE SPAN IS OPEN — every opener ends it - first, as its first act.** REAPER's blocks are read as a DEPTH COUNTER, not a stack - of identities (`[verify — DAW]`, and the same undocumented nested-block behaviour the + Stated as ONE property over the pure state, which is how it is tested + (`tests/test_fx_reclaim.cpp`, at every terminator rather than incidentally in one + scenario): **`!pending()` implies `!span.open()`.** The timer gates every tick on + `pending()`, so a span left open with the wakeup down has nothing that could ever + close it. That is why `applyMode` raises the wakeup BEFORE it opens the span rather + than on its way out — a throw in between would otherwise leak the block with the + reclaimer asleep — and why the state-load terminator closes BEFORE it abandons. +- **NOTHING MAY OPEN AN UNDO BLOCK WHILE THE SPAN IS OPEN — and no site decides that + for itself any more.** REAPER's blocks are read as a DEPTH COUNTER, not a stack of + identities (`[verify — DAW]`, and the same undocumented nested-block behaviour the Gotchas below name): `Undo_EndBlock2` at depth 2->1 mints nothing and drops the label and mask it was handed, and the close that does reach depth 0 mints under whatever label IT carries. So a block opened over the span leaves each close paired with the @@ -134,18 +141,31 @@ decide membership or mode rules. reading: ending the span first yields the switch's point and the opener's as two correctly-labelled points under the counter reading AND under an identity-pairing one, which is why it is stated as an unconditional discipline rather than a workaround for - a behaviour nobody has observed. Ending it first mints the switch's point whole, at - depth 0, and leaves the opener's block the only one in play. The - openers, all of them: `design_view_actions`' `ActionUndoBlock` (whose constructor - ends the span, so all four membership actions inherit it), `view_lanes`' - `mintManagedLanes`, and `shell/capture`'s `render_in_place`. The mirror half is - `applyMode`'s: a REAPPLY never closes a span, precisely because it is reachable from - inside one of those blocks — only a real switch supersedes, and a real switch is - always fired at depth 0. RESIDUAL, out of this rule's reach: an extension undo block - ELSEWHERE (a capture, a bank verb, an ingest) that pumps the message loop can re-enter - the timer, and a drain tick that settles in there closes the span at depth 2->1 with - the same result. Closing that needs every extension-owned `Undo_BeginBlock2` to route - through one opener that ends the span — not attempted here. + a behaviour nobody has observed. **The mechanism is `shell/undo`'s `UndoBlock`, which + every extension-owned block in the tree now opens through — its constructor ends the + span when, and only when, it is the OUTERMOST block, so a nested opener cannot close + at depth 2->1 and hijack its enclosing block.** That directory's `CLAUDE.md` owns the + mechanism and the compile-time enforcement behind it; here it closes three things at + once. First, the openers no longer have to be enumerated by hand — the former list + (`ActionUndoBlock`, `mintManagedLanes`, `render_in_place`) was never the whole set and + omitted ten user-fired blocks in `shell/capture`, `shell/bank_ops` and + `shell/actions`, each of which absorbed the user's own act into the mode switch. + Second, `mintManagedLanes` gets the same nesting criterion as everything else: it is + called from inside `doMoveItems`' and `render_in_place`' blocks, where it used to end + the span unconditionally. Third, the RESIDUAL in the other direction is closed too — + a foreign block's message pump can re-enter the timer, and a drain tick in there would + open its span at depth 2; `fxReclaimTick` now refuses outright while `undoBlockOpen()` + and consumes nothing, so it simply drains on the first tick after the block closes. + The mirror half is still `applyMode`'s: a REAPPLY never closes a span, precisely + because it is reachable from inside one of those blocks — only a real switch + supersedes, and a real switch is always fired at depth 0. + ACCEPTED, and not fixed: `mintManagedLanes` opens its block once its plan is + non-empty, but a non-empty plan whose writes are all already satisfied still mints + nothing — so a background detect tick can end a switch's span having competed with it + for nothing. Telling the two apart means running the plan, which is the write. The + cost is bounded at ONE extra undo point (the switch mints early; its remaining parking + mints again under `kReclaimUndoLabel`), the same degradation a transport deferral + already carries, where the alternative destroys the switch's point outright. - **EVERY project-state load terminates the span itself, at the hook, before REAPER restores anything — `isUndo` does not narrow it.** `fxReclaimAbandonForStateLoad` runs UNBRANCHED from `BeginLoadProjectState` @@ -234,13 +254,13 @@ applies the resulting lane state to live tracks. ## Modules -- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and on a REAL SWITCH ends any prior span and opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none and closes none). Reports what it wrote (`ModeApplyResult`) so a caller holding its own block can discard an empty point. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. +- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and on a REAL SWITCH ends any prior span, raises the reclaimer's wakeup, and opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none and closes none). Reports what it wrote (`ModeApplyResult`) so a caller holding its own block can discard an empty point. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. - `view_park` — the park surface, FLAG DOMAIN ONLY. `parkTrack` captures the pre-park snapshot (flags AND the FX chain's identity-keyed offline state) when the model holds none, drives the four flags, and EXPOSEs a track that reads park-shaped with no record behind it rather than adopting it; `restoreTrack` writes the flags back from the snapshot and spends its flag half (`releaseSnapshotFlags`). Also `trackFlagParm` (the one `Flag` → REAPER-parameter mapping), `writeIfChanged` (the read-before-write every driven parameter goes through), `fxGuidString` (the one FX-identity read snapshot/reclaim both address FX through), `kViewUndoMask`, and `reportExposedTracks`/`describeExposedTracks` (named tracks, once per changed set per project). - `view_lanes` — managed fixed lanes: `mintManagedLanes` splits multi-mode content onto per-mode lanes, `applyLaneOps` drives `C_LANEPLAYS` for the active mode, `reconcileManagedLanes` recovers ownership from durable `P_LANENAME` on project open. Manual lanes are never minted, driven, renamed or reassigned. - `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). It opens no undo block: it runs in the wake of a Ctrl-Z, and its writes are flags only. - `view_tracks` — the scaffolding every write pass sets up: `TrackHandles`/`TrackByGuid`, `readFolderEntries`/`indexHandles`, and `UiRefreshHold` (the `PreventUIRefresh` RAII pair every write phase runs under). - `view_solo` — the `I_SOLO` read/write pair behind the per-mode solo surface, plus `clearTrackSolos`/`restoreTrackSolos`, the outgoing-clear and incoming-replay entry points `view` drives them through. Holds no policy: what to cache, clear, or replay is `core/view/solo_cache`. -- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate; `fxReclaimAbandonForStateLoad` is the state-load terminator, which also stops the drain). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. `fxReclaimActivity` reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected `fxReclaimPending` stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible). +- `fx_reclaim` — the FX convergence loop, off the switch's synchronous path (`TrackFX_SetOffline` was 1897ms of a measured 1942ms switch). `drainTick` moves one track's FX per step against `core/view/fx_state`'s derived work list (Online before Offline; an Offline item waits out `kParkHoldMs` so a fast A→B→A flip moves nothing), budgeted per tick (`kOnlineBudgetMs`/`kOfflineBudgetMs`) and gated off the transport for the Offline direction only. `FxReclaimQueue` owns the derived queue's lifetime across re-entrancy (a plugin unload is assumed to pump). `UndoSpan` owns the ONE undo block the whole drain writes under — opened by `applyMode` on a real switch, else lazily by the first `writeOffline`, never while an extension undo block is open (`fxReclaimTick` refuses the tick outright, consuming nothing), and closed through the single idempotent `fxReclaimEndUndoSpan` that every terminating path routes through (`spanEndsAfter` decides which ticks terminate; `fxReclaimAbandonForStateLoad` is the state-load terminator, which also stops the drain). `ReleaseFlush`/`fxReclaimFlushRelease` make a completed record's spend durable on the project's next save, never on the drain's own initiative. `fxReclaimActivity` reports WHICH DIRECTION the drain is moving for an indicator to paint — Idle whenever nothing may move, so a debounced or transport-held offline item never names a direction nothing is travelling in; undirected `fxReclaimPending` stays the timer's one idle test and must not drive a UI (it is true throughout the debounce, when only the OPPOSITE direction is eligible). ## Gotchas @@ -338,15 +358,15 @@ applies the resulting lane state to live tracks. into sync. - **"Nothing nests on the reapply paths" was FALSE, and cost the switch its undo point.** `render_in_place` and the membership actions each call `applyMode` inside - their own `Undo_BeginBlock2` pair; the reapply opens no block of its own, but a prior - REAL SWITCH's span can still be open — for `kParkHoldMs` plus the whole drain — and - it is a block like any other. Two things nest there, and both are now handled by the - "nothing may open a block while the span is open" invariant: the caller's block over - the span (each caller ends the span first), and the reapply's former unconditional - supersede-close inside the caller's block (only a real switch closes now). What still - nests legitimately: `doMoveItems` and `render_in_place` over `mintManagedLanes`, both - properly paired inside a single outer block, so the SDK's undocumented nested-block - coalescing is load-bearing only for that shape. + their own block; the reapply opens none of its own, but a prior REAL SWITCH's span can + still be open — for `kParkHoldMs` plus the whole drain — and it is a block like any + other. Two things nest there, and both are now handled by the "nothing may open a + block while the span is open" invariant: the caller's block over the span (`UndoBlock` + ends it at the outermost open), and the reapply's former unconditional supersede-close + inside the caller's block (only a real switch closes now). What still nests + legitimately: `doMoveItems` and `render_in_place` over `mintManagedLanes`, all three + `UndoBlock`s, so the SDK's undocumented nested-block coalescing is load-bearing only + for that shape. - `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays attached to its plugin across a chain mutation while parked. That is `[verify — DAW]` (see `fxGuidString` in `view_park.cpp`) and SWS issue #802 is a diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index abe8077..9b3d4cc 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -11,6 +11,7 @@ #include #include "shell/persist/session.h" // flushViewState — the release's durable half +#include "shell/undo/undo_block.h" // undoBlockOpen — the reverse of the nesting rule #include "shell/view/view.h" // modeApplyInProgress, transportBlocksModeSwitch #include "shell/view/view_park.h" // fxGuidString — the one FX-identity read #include "shell/view/view_tracks.h" @@ -233,12 +234,21 @@ void invalidateFxReclaim() { bool fxReclaimDrainInProgress() { return g_draining; } void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { - // Neither guard may close the span: both name a caller that is still on the stack and - // still owns it — the outer drain, or the applyMode that opened it. Both re-tick. + // NO guard here may close the span: each names a caller that is still on the stack and + // still owns it — the outer drain, or the applyMode that opened it. All re-tick. if (g_draining) return; // The model is mid-rewrite and its records are being consumed out from under any // plan derived from it. The apply invalidates on its way out, so nothing is lost. if (modeApplyInProgress()) return; + // The MIRROR of the rule that no block may open over the span: an extension undo + // block is open — a capture, a bank verb, an ingest — and its own message pump is how + // this tick arrived. openSpan from in there would begin at depth 2, and that block's + // close would then mint under the SPAN's label and destroy the user's own point. + // Refused whole rather than written unspanned, which would instead absorb the FX + // toggles into the user's act. Starves nothing: the wakeup, the derived queue and the + // project pin are all untouched (this returns before beginTick), so the first tick + // after the block closes drains exactly as if this one had never been due. + if (undoBlockOpen()) return; DrainLatch latch; // beginTick owns what a pin/project mismatch means. What is local to here: the model diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index 9a4ae38..bff05c7 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -658,7 +658,9 @@ bool fxReclaimDrainInProgress(); // Drains one tick against `proj` (nullptr = current project), re-deriving the queue from // the session's view model first if it was invalidated or if the active project moved // (FxReclaimQueue::beginTick). Returns without touching the project while an applyMode is -// on the stack, while already draining, or when there is no valid active project at all. +// on the stack, while already draining, while any extension undo block is open +// (shell/undo's undoBlockOpen — a refusal that consumes nothing, so the next tick drains), +// or when there is no valid active project at all. // Takes the SESSION rather than the model alone because completing an online spends the // park record's FX half — see FxReclaimQueue::run. void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj); @@ -667,7 +669,8 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj); // ONE Ctrl-Z. Called by applyMode on a REAL SWITCH ONLY — a reapply opens no block at // all — which then returns with the span still open, and the reclaimer closes it once // the parking has drained. No-op if a span is already open, so callers END first (see -// applyMode) rather than nesting. +// applyMode) rather than nesting. applyMode also raises the wakeup BEFORE this call: the +// tick it guarantees is the only thing that can ever close the span again. void fxReclaimBeginUndoSpan(ReaProject* proj); // Sets what the eventual close names the point. Separate from the begin because the @@ -677,8 +680,9 @@ void fxReclaimLabelUndoSpan(const char* label, int mask); // The ONE close, idempotent: every terminating path routes through it — the drain // settling, aborting, or deferring on the transport; the project retargeting or closing; // the next apply superseding it; ANY project-state load; a project save; extension -// unload. A leaked open block would swallow everything the user does afterwards, so -// nothing may return past a terminating condition without calling this. +// unload; and shell/undo's UndoBlock, at its OUTERMOST open. A leaked open block would +// swallow everything the user does afterwards, so nothing may return past a terminating +// condition without calling this. void fxReclaimEndUndoSpan(); // EVERY project-state load, undo-driven or not — Ctrl-Z, Ctrl-Shift-Z, a project open, a diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index b9ddb6f..abedf5d 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -128,6 +128,13 @@ ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetMod // CLAUDE.md. Both calls are idempotent; do not reintroduce either unconditionally. if (realSwitch) { fxReclaimEndUndoSpan(); + // BEFORE the open, never after this function returns. The wakeup is what + // guarantees a tick, and a tick is the only thing that can close this span; a + // throw anywhere below would otherwise leave the block open with the reclaimer + // asleep, swallowing every later edit until a save, a state load, the next switch + // or unload. The wrapper's own invalidate stays where it is — it covers the + // refusal paths above, which never reach this line. + invalidateFxReclaim(); fxReclaimBeginUndoSpan(project); } { diff --git a/src/shell/view/view_lanes.cpp b/src/shell/view/view_lanes.cpp index 982acce..ae3dbda 100644 --- a/src/shell/view/view_lanes.cpp +++ b/src/shell/view/view_lanes.cpp @@ -16,15 +16,13 @@ #include "core/view/lane_keys.h" #include "core/view/view_tree.h" #include "shell/capture/item_read.h" -#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — no block opens over the span +#include "shell/undo/undo_block.h" // the ONE undo-block opener #include "shell/view/view_park.h" // writeIfChanged — the read-before-write every driven parm takes #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_SetMediaTrackInfo_Value #define REAPERAPI_WANT_GetSetMediaTrackInfo_String -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_UpdateTimeline #define REAPERAPI_WANT_CountTrackMediaItems @@ -277,21 +275,46 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { const TrackByGuid trackByGuid = indexHandles(handleByGuid); - // No block may open over a still-open reclaim span — mechanism in this directory's - // CLAUDE.md. Placed past the empty-plan return above deliberately: a detect tick - // that mints nothing must not shorten a span it never competes with. - fxReclaimEndUndoSpan(); + bool changed = false; + { + // Opening ends any reclaim span — but only when this is the OUTERMOST block, so + // the two callers that already hold one (doMoveItems, render_in_place) are not + // hijacked. UndoBlock owns that criterion; do not re-end the span here. + // + // ACCEPTED, not fixed: the plan being non-empty is not the same as its writes + // being needed, so the already-satisfied case below still ends a switch's span + // having minted nothing of its own. Telling the two apart means running the plan + // first, which is the write. The cost is bounded at ONE extra undo point — the + // switch mints early and its remaining parking mints again under + // kReclaimUndoLabel, the same degradation a transport deferral already carries — + // where the alternative, opening over the span, destroys the switch's point + // outright. + // + // `proj` deliberately NOT pinned the way applyMode pins it: nothing between the + // open and the close pumps the message loop, so no tab switch can land between. + UndoBlock undo(proj); + changed = applyMintPlan(model, plan, trackByGuid); - // `proj` deliberately NOT pinned the way applyMode pins it: nothing between this - // begin and its end pumps the message loop, so no tab switch can land between them. - Undo_BeginBlock2(proj); - const bool changed = applyMintPlan(model, plan, trackByGuid); + if (changed) { + // Reapply the active mode's lane visibility so freshly-minted lanes take + // their play/show state immediately. applyMode is deliberately NOT reused + // here — it would re-park/restore whole tracks and recompute parent + // visibility, which a lane-only mint must not touch. + const TogglePlan togglePlan = + model.planToggle(FolderTree{}, model.activeModeId()); + applyLaneOps(trackByGuid, togglePlan.lanes); + + UpdateTimeline(); // a split happened this call — refresh is owed + UpdateArrange(); + + undo.recordPoint("ReaSampler: separate cross-mode content into lanes", + kViewUndoMask); + } + // Otherwise every write was already satisfied: the block's default discard form + // keeps the detect tick from flooding history with empty points. + } if (!changed) { - // Plan was non-empty but every write was already satisfied — discard the empty - // undo point rather than flooding history every detect tick. - Undo_EndBlock2(proj, "", 0); - // The arrange still needs a redraw: this no-op path is reached when a // freshly-inserted item already landed on the active mode's playing lane // (REAPER places new items on the playing lane), so assignItemToLane wrote @@ -299,21 +322,8 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { // alone (no I_FREEMODE change happened, so UpdateTimeline isn't owed) is a // repaint, not a mutation — stays outside the undo block. UpdateArrange(); - return false; } - - // Reapply the active mode's lane visibility so freshly-minted lanes take their - // play/show state immediately. applyMode is deliberately NOT reused here — it - // would re-park/restore whole tracks and recompute parent visibility, which a - // lane-only mint must not touch. - const TogglePlan togglePlan = model.planToggle(FolderTree{}, model.activeModeId()); - applyLaneOps(trackByGuid, togglePlan.lanes); - - UpdateTimeline(); // a split happened this call — refresh is owed - UpdateArrange(); - - Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", kViewUndoMask); - return true; + return changed; } void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp index dfb2529..9eb7273 100644 --- a/tests/test_fx_reclaim.cpp +++ b/tests/test_fx_reclaim.cpp @@ -1433,6 +1433,93 @@ static void testTheDirectionHoldsThroughTheDrainsOwnMessagePump() { CHECK(q.activity(host.clock, host.rolling) == FxReclaimActivity::Idle); // latch released } +// -- 13. the sleep invariant ------------------------------------------------------------ + +// !pending() => !span.open(), at EVERY terminator. A span left open with the wakeup down +// has nothing left that could ever close it: the timer gates every tick on pending(), and +// the block then swallows the user's edits until a save, a state load, the next switch or +// unload. The check below is written as that implication and applied to each pure move +// that can put the loop to sleep, rather than falling out of one scenario. +static bool asleepWithTheSpanOpen(const FxReclaimQueue& q, const UndoSpan& span) { + return !q.pending() && span.open(); +} + +static void testNoTerminatorLeavesTheSpanOpenWithTheLoopAsleep() { + // FIRST, the predicate's own teeth: the violation is expressible, so a CHECK that it + // does not occur is not vacuously true. + { + FxReclaimQueue q; + UndoSpan span; + span.begin(kProjA, "ReaSampler: activate Design view", 9); + q.goIdle(); // the sleep WITHOUT the close — exactly the leak this test forbids + CHECK(asleepWithTheSpanOpen(q, span)); + } + + // 1. Every verdict a tick can come back with, swept whole. The shell closes on + // spanEndsAfter and sleeps on settled, in that order (fxReclaimTick); the property + // is that the second can never fire where the first did not. + for (int bits = 0; bits < 8; ++bits) { + ReclaimRunResult run; + run.settled = (bits & 1) != 0; + run.aborted = (bits & 2) != 0; + run.transportDeferred = (bits & 4) != 0; + + FxReclaimQueue q; + UndoSpan span; + q.invalidate(); + CHECK(q.beginTick(kProjA)); + span.begin(kProjA, "ReaSampler: activate Design view", 9); + + if (spanEndsAfter(run)) span.end(); + if (!run.aborted && run.settled) q.goIdle(); + + CHECK(!asleepWithTheSpanOpen(q, span)); + } + + // 2. The state-load terminator. `abandon` is the one move that clears the wakeup with + // work still owed, which is what makes the close-FIRST ordering load-bearing rather + // than tidy — reverse the two and the loop is asleep under an open block. + { + FxReclaimQueue q; + UndoSpan span; + q.invalidate(); + span.begin(kProjA, "ReaSampler: activate Design view", 9); + span.end(); // fxReclaimAbandonForStateLoad closes before it abandons + q.abandon(); + CHECK(!q.pending()); + CHECK(!asleepWithTheSpanOpen(q, span)); + } + + // 3. The project moving out from under the span. No active project sleeps the loop + // (beginTick's nullptr arm), a tab switch does not — but both close, because the + // span is keyed on its OWN project and neither one is it. + for (ReaProject* arrived : {static_cast(nullptr), kProjB}) { + FxReclaimQueue q; + UndoSpan span; + q.invalidate(); + q.beginTick(kProjA); + span.begin(kProjA, "ReaSampler: activate Design view", 9); + + if (span.project() != arrived) span.end(); + q.beginTick(arrived); + + CHECK(!span.open()); + CHECK(!asleepWithTheSpanOpen(q, span)); + } + + // 4. The terminators with no queue move at all — a project save, an extension unload, + // the next real switch superseding. Each is the idempotent close alone, so the + // wakeup is whatever it already was; the implication has to hold either way. + for (bool woken : {false, true}) { + FxReclaimQueue q; + UndoSpan span; + if (woken) q.invalidate(); + span.begin(kProjA, "ReaSampler: activate Design view", 9); + span.end(); + CHECK(!asleepWithTheSpanOpen(q, span)); + } +} + int main() { testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); @@ -1484,6 +1571,8 @@ int main() { testARollingTransportHidesOfflineWorkFromTheDirectionButNeverOnline(); testTheDirectionHoldsThroughTheDrainsOwnMessagePump(); + testNoTerminatorLeavesTheSpanOpenWithTheLoopAsleep(); + if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; } From 3d95eb715c4d12005b4c5ea4d68b711e1debfcef Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 17:07:56 -0400 Subject: [PATCH 08/14] Undo-block review fixes: exception-safe depth, corrected docs, real test teeth Fixes ctor exception safety, a ValidatePtr2 guard, false SDK-citation and enforcement claims, the span-depth invariant, and vacuous fx_reclaim tests. --- src/shell/bank_ops/CLAUDE.md | 8 ++++++- src/shell/bank_ops/bank_ops.cpp | 9 ++++---- src/shell/undo/CLAUDE.md | 35 +++++++++++++++++++++++------ src/shell/undo/undo_block.cpp | 28 ++++++++++++++++++++--- src/shell/undo/undo_block.h | 15 ++++++++----- src/shell/view/CLAUDE.md | 9 ++++++++ src/shell/view/fx_reclaim.cpp | 20 ++++++++++++++--- tests/test_fx_reclaim.cpp | 40 +++++++++++++++++++++------------ 8 files changed, 126 insertions(+), 38 deletions(-) diff --git a/src/shell/bank_ops/CLAUDE.md b/src/shell/bank_ops/CLAUDE.md index 219229f..6f67a05 100644 --- a/src/shell/bank_ops/CLAUDE.md +++ b/src/shell/bank_ops/CLAUDE.md @@ -33,7 +33,13 @@ calls into that model, it does not reimplement its rules. a Design View mode switch's FX reclaim span is open: without it the verb's own close landed at depth 2->1, minted nothing, and the rename or delete was absorbed into the mode switch, un-undoable on its own. Reason and mechanism: - `src/shell/undo/CLAUDE.md`. + `src/shell/undo/CLAUDE.md`. ACCEPTED, same cost `view_lanes`' `mintManagedLanes` + already carries (`src/shell/view/view_lanes.cpp`'s note at its own `UndoBlock`): + opening `persistBankOp`'s block ends any pending reclaim span unconditionally, + including on the unsaved-project path above, which then records no point of its + own — the switch's point still mints early, and its remaining parking mints again + under `kReclaimUndoLabel`. Bounded at one extra undo point; the alternative + (nesting over the span) destroys the switch's point outright. ## Modules diff --git a/src/shell/bank_ops/bank_ops.cpp b/src/shell/bank_ops/bank_ops.cpp index 5b4989b..1631621 100644 --- a/src/shell/bank_ops/bank_ops.cpp +++ b/src/shell/bank_ops/bank_ops.cpp @@ -44,15 +44,14 @@ std::string mintBankId() { // ext-state is the block's "after" image. // // UNSAVED-PROJECT GUARDRAIL: on an unsaved/no-active project saveToActiveProject() -// no-ops; we still CLOSE the block, but with an empty label + zero flag so REAPER -// discards the point instead of recording a no-effect undo entry. Quiet persist by -// design (mirrors capture, not Design-View) — deliberately no Save-As prompt. +// no-ops, so recordPoint below is skipped — the block closes on its own default +// discard form (empty label, zero flag) instead of recording a no-effect undo entry. +// Quiet persist by design (mirrors capture, not Design-View) — deliberately no +// Save-As prompt. void persistBankOp(ReaSamplerSession& session, const char* label, bool bumpGeneration) { UndoBlock undo(nullptr); if (bumpGeneration) session.bumpBankGeneration(); - // No ext-state write -> record no point; the block's default discard form is what - // keeps an unsaved project from collecting no-effect undo entries. if (session.saveToActiveProject()) undo.recordPoint(label, UNDO_STATE_MISCCFG); } diff --git a/src/shell/undo/CLAUDE.md b/src/shell/undo/CLAUDE.md index 427c8c5..a6e4811 100644 --- a/src/shell/undo/CLAUDE.md +++ b/src/shell/undo/CLAUDE.md @@ -11,13 +11,19 @@ nests over it. ## Invariants -- **`Undo_BeginBlock2` is not callable outside this directory, and that is enforced - by the compiler, not by review.** Only `undo_block.cpp` and `shell/view`'s - `fx_reclaim.cpp` (which owns the span's own begin/end) define - `REAPERAPI_WANT_Undo_BeginBlock2` / `..._Undo_EndBlock2`. Every other TU compiles - under `REAPERAPI_MINIMAL` with no header in its include graph pulling the SDK in - unguarded, so a raw `Undo_BeginBlock2` there is an undeclared identifier. Adding - the WANT define back to a third TU is the one way to defeat this — do not. +- **`Undo_BeginBlock2` is not callable outside this directory in every TU but one, and + that is enforced by the compiler, not by review.** Only `undo_block.cpp` and + `shell/view`'s `fx_reclaim.cpp` (which owns the span's own begin/end) define + `REAPERAPI_WANT_Undo_BeginBlock2` / `..._Undo_EndBlock2`; every other TU compiles + under `REAPERAPI_MINIMAL`, so a raw `Undo_BeginBlock2` there is an undeclared + identifier. **`app/main.cpp` is the one exception** — as the sole + `REAPERAPI_IMPLEMENT` TU it never sets `REAPERAPI_MINIMAL` (it must allocate storage + for whatever ANY TU wants), so the whole API, `Undo_*` included, compiles clean + there too. That TU stays out of the undo business by discipline + (`src/app/CLAUDE.md`: pointers + entry + dispatch only), not because the compiler + stops it — do not add business logic there that would make this matter. Adding the + WANT define back to a third TU is a second, easier way to defeat the enforcement + that DOES exist — do not. - **Ending the reclaim span is part of opening, and ONLY the outermost block does it.** At depth 0 the span is the only block that can be open, so ending it mints the pending mode switch's point whole before this block exists. At depth > 0 an @@ -26,6 +32,12 @@ nests over it. is the same criterion `applyMode`'s reapply is gated on; it lives in the constructor so no call site has to make the judgment. Full reasoning for the rule itself: `src/shell/view/CLAUDE.md`. +- **The constructor's body is one `try`.** `fxReclaimEndUndoSpan` copies a + `std::string` and can throw; a throwing constructor runs no destructor, so an + uncompensated depth increment would stick forever — `undoBlockOpen()` reads true + for the rest of the session, `fxReclaimTick` refuses every tick, and no later + `UndoBlock` is ever outermost. Any throw between the increment and the end of the + constructor rolls the depth back before propagating. - **Closing is a destructor.** No throw between the open and the label decision can leave a block open in the user's project, which is why several call sites can allocate freely between the two. @@ -46,6 +58,15 @@ nests over it. ## Gotchas +- **`Undo_EndBlock2` is guarded by `ValidatePtr2` only when `project_` is a real, + non-null pointer.** `nullptr` means "the active project" — the same convention + `Undo_BeginBlock2` was handed — and `ValidatePtr2` does not accept `nullptr` as a + valid object of any type, so guarding it unconditionally would silently stop every + `UndoBlock undo(nullptr)` site (most of them) from ever closing. A real pointer, by + contrast, can go stale between open and close (several call sites allocate freely + in between), so only that case is validated — the same asymmetry `fx_reclaim.cpp`'s + `closeSpan` already has, for the same reason: its span always holds a real pinned + pointer, never `nullptr` for "active". - The depth counter deliberately does NOT count the reclaim span. The span is the thing this stands off, not a peer. - The counter drops AFTER `Undo_EndBlock2`, not before: the close that reaches depth diff --git a/src/shell/undo/undo_block.cpp b/src/shell/undo/undo_block.cpp index bf3922d..9f10d76 100644 --- a/src/shell/undo/undo_block.cpp +++ b/src/shell/undo/undo_block.cpp @@ -10,6 +10,7 @@ #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_Undo_BeginBlock2 #define REAPERAPI_WANT_Undo_EndBlock2 +#define REAPERAPI_WANT_ValidatePtr2 #include "reaper_plugin_functions.h" namespace reasampler { @@ -31,15 +32,36 @@ UndoBlock::UndoBlock(ReaProject* proj) : project_(proj) { // enclosing UndoBlock already did it, and a close from here would land at depth // 2->1 — minting nothing and hijacking the enclosing block's label instead. That is // the same criterion applyMode's reapply is gated on. - if (g_depth++ == 0) fxReclaimEndUndoSpan(); - Undo_BeginBlock2(proj); + // + // The whole body is one try: fxReclaimEndUndoSpan's closeSpan copies a std::string + // and can throw. A throwing constructor runs no destructor, so an uncompensated + // ++g_depth would stick forever — undoBlockOpen() reads true for the rest of the + // session, fxReclaimTick refuses every tick, and no later UndoBlock is ever + // outermost. Roll the depth back on any throw so the type degrades to "this one + // block failed to open," not "every block after it is now poisoned." + try { + if (g_depth++ == 0) fxReclaimEndUndoSpan(); + Undo_BeginBlock2(proj); + } catch (...) { + --g_depth; + throw; + } } UndoBlock::~UndoBlock() { // Depth drops AFTER the SDK call, not before: the close that reaches depth 0 mints a // point, and anything re-entered through that must still read a block as open rather // than let the drain open a span underneath it. - Undo_EndBlock2(project_, label_.c_str(), mask_); + // + // Guarded the same way fx_reclaim.cpp's closeSpan guards its own Undo_EndBlock2, but + // only for a REAL pinned pointer: `project_ == nullptr` means "the active project", + // the same convention Undo_BeginBlock2 was handed, and ValidatePtr2 does not accept + // nullptr as a valid object of any type — guarding it unconditionally would silently + // stop every `UndoBlock undo(nullptr)` site (most of them) from ever closing. A real + // pointer, by contrast, can go stale: several call sites allocate freely between open + // and close, and the project it named may have closed underneath by now. + if (!project_ || ValidatePtr2(nullptr, project_, "ReaProject*")) + Undo_EndBlock2(project_, label_.c_str(), mask_); --g_depth; } diff --git a/src/shell/undo/undo_block.h b/src/shell/undo/undo_block.h index 7e89723..ef99eb5 100644 --- a/src/shell/undo/undo_block.h +++ b/src/shell/undo/undo_block.h @@ -3,9 +3,12 @@ // opening, so no block can ever nest over it, and the close is a destructor, so no throw // can leave one open in the user's project. // -// Enforced by the SDK's own opt-in: only this TU and fx_reclaim.cpp (which owns the span -// itself) define REAPERAPI_WANT_Undo_BeginBlock2, so `Undo_BeginBlock2` does not even -// name a declared symbol anywhere else in the tree. +// The SDK's WANT opt-in stops a raw Undo_BeginBlock2/_EndBlock2 call in every OTHER TU — +// only this file and fx_reclaim.cpp (the span's own owner) request them, so the symbol is +// undeclared elsewhere. app/main.cpp is the one exception: the sole REAPERAPI_IMPLEMENT TU +// cannot set REAPERAPI_MINIMAL (it must allocate storage for whatever ANY TU wants), so a +// raw call compiles clean there too — kept out by discipline (src/app/CLAUDE.md), not by +// the compiler. #include @@ -16,8 +19,10 @@ namespace reasampler { class UndoBlock { public: - // `proj` is nullptr for the active project, matching Undo_BeginBlock2's own contract - // (reaper_plugin_functions.h:7753). + // `proj` is nullptr for the active project — the general REAPER convention for a + // ReaProject* parameter. reaper_plugin_functions.h documents no proj semantics at all + // for Undo_BeginBlock2/_EndBlock2 (the line once cited here was the #if WANT guard, + // not a doc comment). `[verify — DAW]` explicit UndoBlock(ReaProject* proj); ~UndoBlock(); diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 1840215..1f7f005 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -159,6 +159,15 @@ decide membership or mode rules. The mirror half is still `applyMode`'s: a REAPPLY never closes a span, precisely because it is reachable from inside one of those blocks — only a real switch supersedes, and a real switch is always fired at depth 0. + **Consequently, a span is never open at depth > 0.** The only two openers are a real + switch (always fired straight from an action handler, never from inside another + block) and the reclaimer's own lazy `writeOffline`, which the residual above makes + unreachable at depth > 0. This is what lets `fxReclaimAbandonForStateLoad` and + `fxReclaimFlushRelease` close the span unconditionally with no depth check of their + own, even when the hook that calls them fires from inside an extension `UndoBlock` — + a capture's own save can (`capture_orchestrator`'s `RunCaptureItemAssign` wraps + `RunCapture`, which reaches `Main_SaveProject`). `closeSpan()` there just finds + nothing open. ACCEPTED, and not fixed: `mintManagedLanes` opens its block once its plan is non-empty, but a non-empty plan whose writes are all already satisfied still mints nothing — so a background detect tick can end a switch's span having competed with it diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index 9b3d4cc..fc6979c 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -221,6 +221,11 @@ FxReclaimActivity fxReclaimActivity() { // The pin is only re-tested at beginTick; a poll landing between two ticks can find the // project already gone, and GetPlayStateEx is about to be handed this pointer. if (!ValidatePtr2(nullptr, proj, "ReaProject*")) return FxReclaimActivity::Idle; + // A FOURTH stand-off, alongside no-project/invalid-project/transport: fxReclaimTick + // refuses the tick outright while undoBlockOpen(), so nothing may move for as long as + // one is open — the same "Idle whenever nothing may move" promise this reports for the + // debounce hold and the transport gate. + if (undoBlockOpen()) return FxReclaimActivity::Idle; return g_queue.activity(monotonicMs(), transportBlocksModeSwitch(proj)); } @@ -247,7 +252,10 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { // Refused whole rather than written unspanned, which would instead absorb the FX // toggles into the user's act. Starves nothing: the wakeup, the derived queue and the // project pin are all untouched (this returns before beginTick), so the first tick - // after the block closes drains exactly as if this one had never been due. + // after the block closes drains exactly as if this one had never been due. That last + // claim is this function's own control-flow order — this TU is REAPER-linked, off the + // pure fx_reclaim_tests target, so unlike the queue/span state machine above it, the + // ordering is DAW-verified, not unit-tested. if (undoBlockOpen()) return; DrainLatch latch; @@ -293,7 +301,11 @@ void fxReclaimEndUndoSpan() { closeSpan(); } void fxReclaimAbandonForStateLoad() { // Close FIRST and unconditionally. The abandon below stops the drain and puts the loop // to sleep; the block is what a Ctrl-Z would otherwise close over the wrong state, and - // it is open whether or not a drain is on the stack at all. + // it is open whether or not a drain is on the stack at all. Safe even if this hook were + // reachable from inside an extension UndoBlock (see fxReclaimFlushRelease below for a + // confirmed case of that shape on the save side) — the span can never be open at depth + // > 0 in the first place: src/shell/view/CLAUDE.md's "NOTHING MAY OPEN AN UNDO BLOCK + // WHILE THE SPAN IS OPEN" invariant. closeSpan() just finds nothing to close there. closeSpan(); g_queue.abandon(); } @@ -307,7 +319,9 @@ void fxReclaimFlushRelease(ReaSamplerSession& session) { // different project saved and write into that wrong project's ext state. ReaProject* saving = GetCurrentProjectInLoadSave(); // BEFORE the arm test, and unconditionally: a save is a user act the span must not - // absorb, and the arm is not what decides whether one is open. + // absorb, and the arm is not what decides whether one is open. Same "depth > 0 never + // holds an open span" invariant as fxReclaimAbandonForStateLoad above makes this safe + // even when the save itself is issued from inside an extension UndoBlock. closeSpan(); if (!g_releaseFlush.pendingFor(saving)) return; // Promptless, unlike design_view_actions' persistViewState, which can open a modal diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp index 9eb7273..b37c5a1 100644 --- a/tests/test_fx_reclaim.cpp +++ b/tests/test_fx_reclaim.cpp @@ -1478,7 +1478,19 @@ static void testNoTerminatorLeavesTheSpanOpenWithTheLoopAsleep() { // 2. The state-load terminator. `abandon` is the one move that clears the wakeup with // work still owed, which is what makes the close-FIRST ordering load-bearing rather - // than tidy — reverse the two and the loop is asleep under an open block. + // than tidy. Proved both ways, not asserted by construction: the WRONG order below + // (abandon, then close) is caught mid-sequence by the same predicate the RIGHT order + // is checked against — swapping the two lines here would make the first CHECK below + // fail, which is the teeth `fxReclaimAbandonForStateLoad` closing FIRST relies on. + { + FxReclaimQueue q; + UndoSpan span; + q.invalidate(); + span.begin(kProjA, "ReaSampler: activate Design view", 9); + q.abandon(); // WRONG order: wakeup cleared before the span closes + CHECK(asleepWithTheSpanOpen(q, span)); // the exact leak the real order avoids + span.end(); + } { FxReclaimQueue q; UndoSpan span; @@ -1492,8 +1504,13 @@ static void testNoTerminatorLeavesTheSpanOpenWithTheLoopAsleep() { // 3. The project moving out from under the span. No active project sleeps the loop // (beginTick's nullptr arm), a tab switch does not — but both close, because the - // span is keyed on its OWN project and neither one is it. - for (ReaProject* arrived : {static_cast(nullptr), kProjB}) { + // span is keyed on its OWN project and neither one is it. kProjA is the control: a + // tick landing back on the SAME project must NOT close the span, or a live switch's + // block would drop the flags-and-items mask it opened with mid-drain. Sweeping all + // three, with a branch-dependent CHECK, is what gives the guard real teeth — the + // original two-value sweep always took the close branch, so the no-close branch was + // never exercised. + for (ReaProject* arrived : {kProjA, static_cast(nullptr), kProjB}) { FxReclaimQueue q; UndoSpan span; q.invalidate(); @@ -1503,21 +1520,16 @@ static void testNoTerminatorLeavesTheSpanOpenWithTheLoopAsleep() { if (span.project() != arrived) span.end(); q.beginTick(arrived); - CHECK(!span.open()); + if (arrived == kProjA) CHECK(span.open()); // same project: nothing to close + else CHECK(!span.open()); CHECK(!asleepWithTheSpanOpen(q, span)); } // 4. The terminators with no queue move at all — a project save, an extension unload, - // the next real switch superseding. Each is the idempotent close alone, so the - // wakeup is whatever it already was; the implication has to hold either way. - for (bool woken : {false, true}) { - FxReclaimQueue q; - UndoSpan span; - if (woken) q.invalidate(); - span.begin(kProjA, "ReaSampler: activate Design view", 9); - span.end(); - CHECK(!asleepWithTheSpanOpen(q, span)); - } + // the next real switch superseding. Each is the idempotent close alone, with no + // guard and nothing else to sweep, so it reduces to end()'s own idempotency + // (testTheSpanOpensOnceAndClosesOnceHoweverManyTimesItIsAsked) rather than adding a + // separate claim here. } int main() { From 20fef2a3bf43b2b15ff40c182079d9ba7b9c029d Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 18:03:58 -0400 Subject: [PATCH 09/14] Keep the extension's tick out of REAPER's own project load, and re-earn the park holds across one load --- src/app/main.cpp | 11 +++++++ src/shell/view/CLAUDE.md | 34 +++++++++++++++++--- src/shell/view/fx_reclaim.h | 24 +++++++++++--- tests/test_fx_reclaim.cpp | 64 ++++++++++++++++++++++++++++++++++++- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 0ae42b1..ef62f3a 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -183,6 +183,17 @@ static void OnTimer() // the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live. if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); + // A project load or save is IN FLIGHT, so this tick is running inside its own message + // pump — a restore brings plugins online and a plugin load pumps. Everything below + // would then act on a half-restored project: poll() reads the pre-load that + // OnBeginLoadProjectState defers past precisely because it is wrong, and the reclaim + // tick writes FX into a project REAPER is still rewriting. The extension's other + // re-entrancy guards cover OUR pumps; this one covers REAPER's. Consumes nothing, so + // the first tick after the load runs as if this one had never been due. Self-lowering + // (reaper_plugin_functions.h:1584-1587), and placed after the realtime drive so that + // fast path keeps its single-pointer-test shape. See src/shell/view/CLAUDE.md. + if (GetCurrentProjectInLoadSave()) return; + // poll()'s undo/redo-reload and project-switch Load branches call // loadFromProject, replacing g_session.view() wholesale — deferred here while // an applyMode is on the stack (reachable if a pumped message loop re-enters diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 1f7f005..79b86ae 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -108,9 +108,12 @@ decide membership or mode rules. ONE undo block: `applyMode` opens it on a REAL SWITCH ONLY (`fx_reclaim`'s `UndoSpan`) and RETURNS WITH IT OPEN, for the reclaimer to close once the drain has converged. This does not move an FX write back onto the switch — the writes still - run on the timer, off the 1897ms path. Whether a block held from an ACTION into a - later timer callback holds is `[verify — DAW]`; if it does not, the switch and its - parking land as two points, which is the intended degradation. + run on the timer, off the 1897ms path. **DAW-CONFIRMED, no longer inferred:** a block + opened in the `applyMode` ACTION and closed several timer callbacks later by the drain + holds — switch, then an undisturbed parking drained to completion, then one Ctrl-Z gave + exactly ONE undo entry and returned to the previous mode. (The two-point degradation + this once hedged against never materialised; the degradations that DO occur are the + transport deferral and `mintManagedLanes`' early close, both named below.) - **No path may return past a terminating condition with the span open.** A leaked block swallows every edit the user makes afterwards, indefinitely, so every terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the @@ -225,7 +228,30 @@ decide membership or mode rules. OFFLINE work once a track has read continuously parked for `kParkHoldMs` (3000ms). A flip inside that window flips the flags twice but the debounce never matures, so the reclaimer finds every chain already where the record - wants it and writes nothing. + wants it and writes nothing. **Undoing a switch mid-park is that same flip**, so a + state load DROPS the holds where an invalidation keeps them (`FxReclaimQueue::abandon`, + and again at the end of `run` for an abandon that arrived through a write's own pump, + which found the clock moved into the drain's local). Keeping them fired the post-load + re-park on the FIRST tick after the load, with zero debounce, in the wake of REAPER's + own restore; re-earning them is what makes the undo cost nothing and keeps the writes + clear of the load. +- **NOTHING THE EXTENSION DOES PER TICK RUNS WHILE REAPER IS IN A PROJECT LOAD OR SAVE.** + `BeginLoadProjectState` fires before REAPER restores anything, and the restore itself + brings plugins online — a plugin load pumps the message loop, which is how a timer tick + lands INSIDE the load. `main.cpp`'s `OnTimer` therefore returns immediately while + `GetCurrentProjectInLoadSave()` names a project + (`reaper_plugin_functions.h:1584-1587`), self-lowering, with no latch to leak. Without + it, that tick's `poll()` reads the half-restored `` — the very pre-load value + the hook defers past because it is wrong — the reapply plans against it, and the reclaim + tick writes FX into a project REAPER is still rewriting; each such round is its own + settle-and-close, so a re-convergence that owes ONE point costs one per round. That last + step is reachable by inspection, not measured: the DAW report it answers is one undo + entry per FX after a Ctrl-Z mid-drain, and whether this is the whole of that cause is + `[verify — DAW]`. The extension's other re-entrancy guards (`modeApplyInProgress`, + `fxReclaimDrainInProgress`) cover OUR pumps only; this is the one that covers REAPER's. + It consumes nothing — reload request, load signal, wakeup, derived queue and pin all + stand. The realtime-capture drive deliberately sits AHEAD of it, keeping its + single-pointer-test idle path and its behaviour across a load unchanged. - **Show-both semantics:** a per-track "pin visible across modes" flag re-enables processing whenever shown. A show-both leaf appears in every mode's visible set and is never parked — its driven flags stay at snapshot/restored values, FX diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index bff05c7..e131fea 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -386,8 +386,18 @@ public: abandoned_ = true; discard(); pending_ = false; - // The pin STAYS — it is what the next tick's retarget tests the debounce clock - // against, and that clock outlives this exactly as it outlives an invalidation. + // The HOLDS GO, where an invalidation keeps them — the one place the two differ on + // the clock, and for the same reason they differ on the wakeup. A hold records "this + // track has read continuously parked since T", an observation about the project this + // load is replacing; after the load the chains may sit anywhere (an undo of a park + // restores them online) and no such continuity is in evidence. Keeping it fired the + // post-load re-park on the FIRST tick after the load, with zero debounce, right in + // the wake of REAPER's restore. Undoing a switch mid-park is precisely the A->B->A + // flip kParkHoldMs exists to make free: re-earning the hold is what makes it free + // again, and what keeps the re-convergence's writes clear of the load. + clock_.forget(); + // The pin STAYS — beginTick compares it to decide a retarget, and losing it there + // would read as "no project" rather than "same project, nothing derived yet". } // Read by the shell's host on every step; false again from the next run(), because the @@ -477,6 +487,11 @@ public: // The hold is keyed on the track and outlives any re-derivation, so it comes // back whether or not the drain's own work survives. clock_.swap(clock); + // ...but not across a state load. An abandon reaching us through a write's own + // message pump found the clock already moved out into the drain's local, so its + // forget cleared an empty member; redo it now the holds are back. `abandoned_` is + // cleared at this call's entry, so it can only mean an abandon landed inside it. + if (abandoned_) clock_.forget(); // Assembled field by field rather than positionally: the aggregate is read by // the undo span's close decision now, and a silently mis-slotted bool there @@ -686,8 +701,9 @@ void fxReclaimLabelUndoSpan(const char* label, int mask); void fxReclaimEndUndoSpan(); // EVERY project-state load, undo-driven or not — Ctrl-Z, Ctrl-Shift-Z, a project open, a -// File > Revert, a new project. Ends the span, stops any drain on the stack, and leaves the -// loop ASLEEP until something that re-read the model wakes it (FxReclaimQueue::abandon). +// File > Revert, a new project. Ends the span, stops any drain on the stack, drops the +// debounce holds, and leaves the loop ASLEEP until something that re-read the model wakes +// it — FxReclaimQueue::abandon owns all four and the reasoning behind each. // // Called from the projectconfig BeginLoadProjectState hook, which fires BEFORE REAPER // restores anything: that is the ONLY point at which the block can still close over the diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp index b37c5a1..685078f 100644 --- a/tests/test_fx_reclaim.cpp +++ b/tests/test_fx_reclaim.cpp @@ -1272,14 +1272,75 @@ static void testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack( CHECK(q.stale()); CHECK(!q.pending()); - // ...and the stop belonged to that tick alone. The reloaded project's own drain writes. + // ...and the stop belonged to that tick alone. The reloaded project's own drain writes — + // but not before its holds are earned again: the abandon dropped them (it reached the + // clock through the drain's own local, see FxReclaimQueue::run), so the re-park starts + // its 3s from the first post-load tick rather than inheriting the pre-load observation. host.abandonAfterWrite = -1; q.adopt(derive(vm, {"{T1}", "{T2}"})); + CHECK(q.run(host).writes == 0); // first post-load tick: the holds start here + CHECK(!host.offlineAt("{T1}", 1)); + host.clock += kParkHoldMs; CHECK(q.run(host).writes == 2); // {T1}'s remaining slot, and {T2}'s whole chain CHECK(host.offlineAt("{T1}", 1)); CHECK(host.offlineAt("{T2}", 0)); } +static void testTheWholePostLoadReconvergenceIsOneSpanHoweverFarTheParkingGot() { + // The acceptance property behind a Ctrl-Z landing anywhere in the drain: whatever + // fraction of the parking had completed, everything the reloaded project owes rides + // ONE span and costs ONE point. Two things carry it — the abandon dropping the holds, + // so nothing writes on the tick that re-derives (the load's own wake), and the lazy + // open being the drain's, so the many ticks that follow share one block. + ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}"}}, + {"{T2}", {"{FX-C}"}}, + {"{T3}", {"{FX-D}"}}}); + FakeHost host; + host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}"}); + host.chains["{T2}"] = onlineChain({"{FX-C}"}); + host.chains["{T3}"] = onlineChain({"{FX-D}"}); + host.stepCostMs = 40.0; // over the offline budget: one track per tick, many ticks + + UndoSpan span; // the switch's, still open when the undo arrives + CHECK(span.begin(kProjA, "ReaSampler: activate Design view", 9)); + host.span = &span; + + FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}", "{T3}"}); + q.run(host); + host.clock += kParkHoldMs; + host.reentrant = &q; + host.abandonAfterWrite = 1; // Ctrl-Z one write in — the parking is part-done + q.run(host); + CHECK(!span.open()); // the terminator closed the switch's point over pre-load state + host.reentrant = nullptr; + host.abandonAfterWrite = -1; + + // The reloaded model's reapply raises the wakeup and the queue re-derives. + q.invalidate(); + q.adopt(derive(vm, {"{T1}", "{T2}", "{T3}"})); + + int closes = 0; + auto tick = [&]() { + const ReclaimRunResult r = q.run(host); + if (spanEndsAfter(r) && span.end()) ++closes; + return r; + }; + + CHECK(!tick().settled); // the holds start here, so this tick writes nothing at all + CHECK(host.spanOpens == 0); + CHECK(!span.open()); + CHECK(closes == 0); + + host.clock += kParkHoldMs; + int ticks = 0; + while (!tick().settled && ++ticks < 20) {} + CHECK(ticks >= 2); // it really did take several ticks to converge... + CHECK(host.spanOpens == 1); // ...under ONE span, opened by the drain's first write + CHECK(closes == 1); // ...closed exactly once, on the tick that settled + CHECK(!span.open()); + CHECK(host.offlineAt("{T1}", 1) && host.offlineAt("{T2}", 0) && host.offlineAt("{T3}", 0)); +} + static void testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel() { // File > Revert is the load that re-reads NOTHING: it keeps the same ReaProject, so the // session's identity poll answers NoOp, no reload follows and therefore no reapply @@ -1576,6 +1637,7 @@ int main() { testADrainNoSwitchOpenedMintsOnePointOfItsOwn(); testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(); + testTheWholePostLoadReconvergenceIsOneSpanHoweverFarTheParkingGot(); testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel(); testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold(); From d5ff476a19ca3a0c7134f5086a176eac1663a3fb Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Fri, 7 Aug 2026 19:51:37 -0400 Subject: [PATCH 10/14] Make Design View membership edits undoable: the view_state write moves inside the block All four actions share one body; the point turns on a model diff, so a no-op leaves history and the Undo button alone. The Save-As prompt stays outside. --- src/shell/actions/CLAUDE.md | 12 ++ src/shell/actions/design_view_actions.cpp | 205 ++++++++++++---------- src/shell/view/CLAUDE.md | 46 +++-- src/shell/view/view.cpp | 11 +- src/shell/view/view.h | 19 +- src/shell/view/view_lanes.h | 4 +- src/shell/view/view_park.h | 12 +- 7 files changed, 177 insertions(+), 132 deletions(-) diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index 01bc6be..3e1e13e 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -36,6 +36,18 @@ is owned by other directories and only skinned here. reclaim span is open; the mechanism and its compile-time enforcement are `src/shell/undo/CLAUDE.md`'s. Bank index verbs additionally stay one batched `UNDO_STATE_MISCCFG` point apiece, so one bank operation is one Ctrl-Z. +- **A Design View membership edit's undo point holds its `view_state` write — so the + persist runs INSIDE the block, and the point turns on a MODEL DIFF, not on "REAPER + was written to".** All four membership actions (tag, untag / tag→Arrange, show-both, + the item moves) share one body, `design_view_actions`' `runMembershipEdit`. + Everything the reapply they drive writes is the four driven track flags, which mint + no undo state at all (`src/shell/view/CLAUDE.md`), so a point closed BEFORE the + ext-state write covered no change: the edit was not undoable and rode whatever point + the user's next act minted. Two consequences bind. The persist's modal Save-As half + is split off and runs before the block opens — a modal may not hold a block open. + And a no-op edit records no label, because a recorded label re-titles the Undo button + whether or not REAPER appends an entry. Same shape as `shell/bank_ops`' + `persistBankOp`, for the same reason. - **The prune action is the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). It halts on `blockedByTracking` and prints whichever blockers fired — the malformed ledger, diff --git a/src/shell/actions/design_view_actions.cpp b/src/shell/actions/design_view_actions.cpp index 24bb279..5c947fd 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -104,16 +104,13 @@ std::vector selectedTrackGuids() { } // Reapplies the CURRENT active mode so a membership mutation takes visible effect -// immediately (park/unpark/re-derive parents), answering whether it wrote any project -// state — the caller's block mints only if something did. `applied` is deliberately -// discarded: a refused reapply leaves the membership edit standing and unapplied, and -// the user's next edit or mode switch reapplies it (a refusal writes nothing, so it -// reports false here and the block discards). What must NOT be dropped with it is the +// immediately (park/unpark/re-derive parents). `applied` is deliberately discarded: a +// refused reapply leaves the membership edit standing and unapplied, and the user's +// next edit or mode switch reapplies it. What must NOT be dropped with it is the // reclaimer's queue, which is why applyMode invalidates on its refusal path too // (view.cpp). -bool reapplyActiveMode() { - return applyMode(g_session->view(), g_session->view().activeModeId(), nullptr) - .wroteProjectState; +void reapplyActiveMode() { + applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); } constexpr int kFreeModeFixedLanes = 2; // I_FREEMODE value; mirrors the shell's constant @@ -147,33 +144,45 @@ std::vector selectedRetagItems() { // serialization. Every other action keeps the full save. enum class PersistScope { Full, ViewOnly }; -// Persists the bank + Design-View model after every state-changing action so the -// view model is not lost across save/close/reopen. If membership is non-empty and -// the project is unsaved, prompts Save-As first (mirrors the flow capture uses) — -// DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed. -void persistViewState(PersistScope scope) { - if (!g_session->view().membership().empty()) { - ReaProject* proj = EnumProjects(-1, nullptr, 0); - if (proj) { - auto readRppPath = [&]() -> std::string { - std::vector buf(4096, '\0'); - EnumProjects(-1, buf.data(), static_cast(buf.size())); - return std::string(buf.data()); - }; +// The persist's MODAL half, split from the write below so a caller can run the two on +// opposite sides of an undo block. If membership is non-empty and the project is +// unsaved, prompts Save-As (mirrors the flow capture uses) — DAW-ONLY: +// Main_SaveProject(proj, true) blocks until the dialog is dismissed. False means the +// user declined and nothing may be written. +bool ensurePersistTarget() { + if (g_session->view().membership().empty()) return true; - if (readRppPath().empty()) { - Main_SaveProject(proj, true); - if (readRppPath().empty()) { // still empty -> user cancelled - ShowConsoleMsg( - "ReaSampler: Design View state will not persist until " - "the project is saved.\n"); - return; - } - } - } - } - if (scope == PersistScope::ViewOnly) g_session->saveViewStateOnly(); - else g_session->saveToActiveProject(); + ReaProject* proj = EnumProjects(-1, nullptr, 0); + if (!proj) return true; // no active project — the write below no-ops on its own + + auto readRppPath = [&]() -> std::string { + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + return std::string(buf.data()); + }; + + if (!readRppPath().empty()) return true; + + Main_SaveProject(proj, true); + if (!readRppPath().empty()) return true; // still empty -> user cancelled + + ShowConsoleMsg("ReaSampler: Design View state will not persist until " + "the project is saved.\n"); + return false; +} + +// The persist's WRITE half: the bank + Design-View model, so neither is lost across +// save/close/reopen. Returns whether the write was ISSUED — false means no active +// project or an unsaved one, with NOTHING written (session.h owns that contract), which +// is the only reading a caller may turn into a discarded undo point. +bool writePersistedState(PersistScope scope) { + return scope == PersistScope::ViewOnly ? g_session->saveViewStateOnly() + : g_session->saveToActiveProject(); +} + +// The two halves as a caller holding no undo block runs them. +void persistViewState(PersistScope scope) { + if (ensurePersistTarget()) writePersistedState(scope); } // The footer segment already reads disabled while the transport runs, but an action can @@ -211,17 +220,53 @@ void doActivateMode(const std::string& modeId) { bankPanelInvalidate(); // repaint the footer [Arrange|Design] toggle immediately } -// The block each membership-edit action below owns. The reapply they drive opens none -// of its own — nothing it writes mints a point unaided — so a user-fired tag stays one -// Ctrl-Z only because the ACTION spans it, and the label names the act the user -// actually fired rather than the reapply it happened to trigger. Every one of them -// closes the block before persistViewState, which can pop a modal Save-As. +// The drive half of a membership edit: what runs inside the block, answering whether +// it made a project change of its own that a point must hold even with nothing +// persisted. The reapply alone never does — everything it writes is the four driven +// flags, which mint no undo state (src/shell/view/CLAUDE.md). +bool driveReapply() { + reapplyActiveMode(); + return false; +} + +// The item moves' drive: re-drive the same minting/apply path auto-tag uses — mint or +// split lanes for any track whose items now span modes, then reassert active-mode lane +// visibility. A mint moves items between lanes, which is a real project change whether +// or not the persist below lands. +bool driveMintLanesAndReapply() { + const bool minted = mintManagedLanes(g_session->view(), nullptr); + reapplyActiveMode(); + return minted; +} + +// The ONE body behind all four membership edits (this directory's CLAUDE.md owns the +// rule; what follows is why the ORDER is what it is). `mutate` is a pure model edit — +// nothing in it reaches REAPER. // -// The point is recorded only where the reapply actually wrote: re-tagging tracks that -// already sit in that mode writes nothing to the project, and the ("", 0) discard form -// UndoBlock closes on by default is real from a hand-fired action (DAW-measured, -// src/shell/view/CLAUDE.md). The membership move itself is never in the point either -// way — persistViewState runs after the close. +// The prompt sits between the edit and the block because it is pinned on both sides: +// its condition reads the POST-edit membership (a first-ever tag on an unsaved project +// must still prompt), and a modal may not stand inside an open block. The persist then +// runs INSIDE, being the only thing here a point can hold; a re-tag of tracks already +// in that mode re-writes byte-identical ext state, which is why the label turns on the +// model diff and not on the write. +template +void runMembershipEdit(Mutate&& mutate, bool (*drive)(), const std::string& label, + int mask) { + const MembershipIndex before = g_session->view().membership(); + mutate(); + const bool changed = !(g_session->view().membership() == before); + + const bool persistable = ensurePersistTarget(); + + UndoBlock undo(nullptr); + const bool structural = drive(); + const bool persisted = persistable && writePersistedState(PersistScope::Full); + if (structural || (changed && persisted)) + undo.recordPoint(label, mask); + // Otherwise the block closes on its ("", 0) discard form — real from a hand-fired + // action (DAW-measured, src/shell/view/CLAUDE.md) — leaving history and the Undo + // button both untouched. +} // tag() replaces any prior single-mode membership (a leaf lives in one mode; the // cross-mode case is show-both). @@ -229,13 +274,11 @@ void doTag(const std::string& modeId) { const std::vector guids = selectedTrackGuids(); if (guids.empty()) return; // nothing selected — no act, no undo point (cf. doMoveItems) - { - UndoBlock undo(nullptr); - for (const std::string& g : guids) g_session->view().membership().tag(g, modeId); - if (reapplyActiveMode()) - undo.recordPoint("ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask); - } - persistViewState(PersistScope::Full); + runMembershipEdit( + [&] { + for (const std::string& g : guids) g_session->view().membership().tag(g, modeId); + }, + &driveReapply, "ReaSampler: tag selected tracks -> " + modeId, kViewUndoMask); } // Shared body behind "Untag selected" and "Tag -> Arrange" — Arrange is the absence @@ -244,13 +287,11 @@ void doUntag() { const std::vector guids = selectedTrackGuids(); if (guids.empty()) return; - { - UndoBlock undo(nullptr); - for (const std::string& g : guids) g_session->view().membership().untag(g); - if (reapplyActiveMode()) - undo.recordPoint("ReaSampler: untag selected tracks", kViewUndoMask); - } - persistViewState(PersistScope::Full); + runMembershipEdit( + [&] { + for (const std::string& g : guids) g_session->view().membership().untag(g); + }, + &driveReapply, "ReaSampler: untag selected tracks", kViewUndoMask); } // Flips each track's pin independently — the honest semantics of a toggle on a @@ -259,14 +300,12 @@ void doShowBoth() { const std::vector guids = selectedTrackGuids(); if (guids.empty()) return; - { - UndoBlock undo(nullptr); - MembershipIndex& m = g_session->view().membership(); - for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g)); - if (reapplyActiveMode()) - undo.recordPoint("ReaSampler: toggle show-both on selected tracks", kViewUndoMask); - } - persistViewState(PersistScope::Full); + runMembershipEdit( + [&] { + MembershipIndex& m = g_session->view().membership(); + for (const std::string& g : guids) m.setShowBoth(g, !m.isShowBoth(g)); + }, + &driveReapply, "ReaSampler: toggle show-both on selected tracks", kViewUndoMask); } // Retag the current ITEM selection to `targetMode` (empty => untag -> Arrange @@ -281,28 +320,18 @@ void doMoveItems(const std::string& targetMode) { const std::vector ops = planItemRetag(selected, targetMode); if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty - MembershipIndex& membership = g_session->view().membership(); - - { - UndoBlock undo(nullptr); - for (const ItemRetagOp& op : ops) { - if (op.untag) membership.untag(op.guid); - else membership.tag(op.guid, op.modeId); - } - // Re-drive the same minting/apply path auto-tag uses: mint/split lanes for any - // track whose items now span modes, then reassert active-mode lane visibility. - // Either half writing is enough to mint; a retag that split no lanes and drove - // no flag leaves nothing for a point to hold. - const bool minted = mintManagedLanes(g_session->view(), nullptr); - const bool reapplied = reapplyActiveMode(); - if (minted || reapplied) - undo.recordPoint(targetMode.empty() - ? std::string("ReaSampler: untag selected items") - : "ReaSampler: move selected items -> " + targetMode, - -1); - } - - persistViewState(PersistScope::Full); + runMembershipEdit( + [&] { + MembershipIndex& membership = g_session->view().membership(); + for (const ItemRetagOp& op : ops) { + if (op.untag) membership.untag(op.guid); + else membership.tag(op.guid, op.modeId); + } + }, + &driveMintLanesAndReapply, + targetMode.empty() ? std::string("ReaSampler: untag selected items") + : "ReaSampler: move selected items -> " + targetMode, + -1); } } // namespace diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 79b86ae..4347589 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -94,14 +94,18 @@ decide membership or mode rules. because a block there could only manufacture the point a project open or a membership edit must not cost. This is why the same `I_FREEMODE` write is blocked at one site and not the other: minting SPLITS a track, `applyLaneOps` only re-states which lane plays. - Second clause, binding every site that does open one: **a block that wrote nothing - closes on the discard form** (`Undo_EndBlock2(proj, "", 0)` — real from a hand-fired - action, per the measurement above), so re-tagging tracks already in that mode leaves - no empty point. The one exception is a REAL SWITCH, which mints written state or not: - it is an explicitly fired action and has to stay undoable even where the plan found - nothing to write. "Wrote" means a REAPER write throughout — model moves persist - through `persistViewState`, outside every block, so no point could carry them - (`ModeApplyResult`, `view.h`). + Second clause, binding every site that does open one: **a block with nothing REAPER + can hold closes on the discard form** (`Undo_EndBlock2(proj, "", 0)` — real from a + hand-fired action, per the measurement above), so re-tagging tracks already in that + mode leaves no empty point and no relabelled Undo button. The one exception is a REAL + SWITCH, which mints written state or not: it is an explicitly fired action and has to + stay undoable even where the plan found nothing to write. "Nothing REAPER can hold" is + NOT "no REAPER write", and reading it that way was a shipped defect: the four driven + flags ARE a REAPER write and mint nothing, so a site predicated on writes names points + REAPER then declines to append — history unchanged, Undo button relabelled. What a + membership edit's point holds is its `view_state` write, which is why that write is + INSIDE the block and the predicate is the model diff + (`shell/actions/design_view_actions`' `runMembershipEdit`). - **ONE mode switch is ONE undo point — the background FX parking included.** The driven flags, the lane state, AND the per-FX offline/online the reclaimer writes minutes-of-plugin-loads later all roll back in a single Ctrl-Z, because they share @@ -222,8 +226,9 @@ decide membership or mode rules. `render_in_place`'s inner reapply. The open half is the criterion above (a reapply re-asserts derived state and is nobody's act); the close half is the nesting rule above (a reapply runs inside its driver's block, so a supersede-close from there - would land at depth 2->1 and hijack it). It reports what it wrote instead, and the - driver holding the block decides — `ModeApplyResult::wroteProjectState`. + would land at depth 2->1 and hijack it). It reports only whether it `applied`; a + driver holding a block decides its point from its OWN model diff, never from what the + reapply wrote, and `ModeApplyResult` no longer offers that answer to be misread. - **A rapid A→B→A flip moves zero plugins.** The reclaimer only starts a park's OFFLINE work once a track has read continuously parked for `kParkHoldMs` (3000ms). A flip inside that window flips the flags twice but the debounce @@ -289,7 +294,7 @@ applies the resulting lane state to live tracks. ## Modules -- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and on a REAL SWITCH ends any prior span, raises the reclaimer's wakeup, and opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none and closes none). Reports what it wrote (`ModeApplyResult`) so a caller holding its own block can discard an empty point. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. +- `view` — Design View shell: runs the pure planner over the live folder tree and iterates its park/restore plans, applies managed-lane and parent-visibility writes, and on a REAL SWITCH ends any prior span, raises the reclaimer's wakeup, and opens the ONE undo span all of that rides (`fx_reclaim` is what closes it; a reapply opens none and closes none). Reports whether it applied and nothing else (`ModeApplyResult`) — what it wrote is deliberately not an answer it gives, because none of it mints. Owns the one discriminator (`target != active`) that separates a real switch from a reapply, and with it both the playback gate (`transportBlocksModeSwitch`) and the solo cache/clear/restore seams. **Never touches master or `B_MUTE`.** FX are NOT written here — see `fx_reclaim`. - `view_park` — the park surface, FLAG DOMAIN ONLY. `parkTrack` captures the pre-park snapshot (flags AND the FX chain's identity-keyed offline state) when the model holds none, drives the four flags, and EXPOSEs a track that reads park-shaped with no record behind it rather than adopting it; `restoreTrack` writes the flags back from the snapshot and spends its flag half (`releaseSnapshotFlags`). Also `trackFlagParm` (the one `Flag` → REAPER-parameter mapping), `writeIfChanged` (the read-before-write every driven parameter goes through), `fxGuidString` (the one FX-identity read snapshot/reclaim both address FX through), `kViewUndoMask`, and `reportExposedTracks`/`describeExposedTracks` (named tracks, once per changed set per project). - `view_lanes` — managed fixed lanes: `mintManagedLanes` splits multi-mode content onto per-mode lanes, `applyLaneOps` drives `C_LANEPLAYS` for the active mode, `reconcileManagedLanes` recovers ownership from durable `P_LANENAME` on project open. Manual lanes are never minted, driven, renamed or reassigned. - `view_reconcile` — the repair pass for a project state REAPER reloaded under us: `noteAppliedState` records what an apply left driven; `reconcileAppliedFlags` diffs that fingerprint against the reloaded model and hands back any track the reload no longer records but still reads driven — the fix for `UNDO_STATE_TRACKCFG` not carrying `B_SHOWINTCP`/`B_SHOWINMIXER` (DAW-confirmed). It opens no undo block: it runs in the wake of a Ctrl-Z, and its writes are flags only. @@ -339,10 +344,11 @@ applies the resulting lane state to live tracks. unsaved project — land inside the block after all. That is a consequence of the span, not a decision made here; it does not resolve the redo hypothesis below, which turns on capture-time semantics the SDK does not document. `[verify — DAW]` - that a Save-As dialog opening under an open undo block is harmless. On the reapply - path the write stays OUTSIDE every block, as it always did: the reapply opens none, - and a membership action's own `ActionUndoBlock` closes before `persistViewState` for - exactly this modal-Save-As reason. The UNDO direction still comes + that a Save-As dialog opening under an open undo block is harmless. On the MEMBERSHIP + path the write is now deliberately inside the action's own `UndoBlock` — it is the + only thing there a point can hold — and the persist is split so its modal Save-As half + runs BEFORE that block opens (`shell/actions/design_view_actions`, which owns the + reasoning). The UNDO direction still comes out right — the point closed over the ext state as it stood BEFORE the switch, which is what a Ctrl-Z should restore — but by position, not by design. The REDO direction is what this leaves open, and the cost is worse than the @@ -363,9 +369,13 @@ applies the resulting lane state to live tracks. semantics for the state mask; (2) that a redo fires `BeginLoadProjectState` with `isUndo == true` so the session really does reload the stale ext state (asserted in `main.cpp`, never observed). If (2) is false the in-memory model - keeps its snapshots and the redo is consistent. `[verify — DAW]`. Do not "fix" - it by moving `persistViewState` inside the block — it can open a modal Save-As; - whether to split the persist is a separate decision. + keeps its snapshots and the redo is consistent. `[verify — DAW]`. Link (1) is the + same capture-time reading the membership path's fix now rests on, and the nearest + thing to evidence for it is `reaper_plugin.h:1198-1201`: REAPER serializes an + extension's state for a point through `SaveExtensionConfig` with `isUndo` set — i.e. + at point creation, not later. Settling it by moving the SWITCH path's + `persistViewState` is still the wrong move: that one runs with the span open, so its + modal would stand inside a block nothing closes until the drain converges. - A DIFFERENT strand entirely, which the EXPOSE report does NOT cover: an absent or malformed `view_state` (`loadViewModel`, `ext_state_io.cpp`) is not a parked-chain reading at all — it falls back to a DEFAULT model (active mode diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index abedf5d..7a2fe9a 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -116,7 +116,6 @@ ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetMod (targetMode ? targetMode->displayName : targetModeId) + " view"; bool laneModeChanged = false; - bool wrote = false; // REAPER writes only — see ModeApplyResult std::vector exposedNames; // named while handles are fresh, reported after the writes // ONLY A REAL SWITCH, both halves — end a prior switch's still-open span, then open @@ -155,7 +154,6 @@ ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetMod MediaTrack* tr = resolve(trackByGuid, guid); if (!tr) continue; // stale/deleted GUID — prune const TrackParkResult r = parkTrack(model, guid, tr, tp.flags); - if (r.wrote) wrote = true; // Named here, not after the block: TrackList_AdjustWindows below can // invalidate the handle. if (r.exposed) exposedNames.push_back(trackDisplayName(tr)); @@ -166,14 +164,13 @@ ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetMod const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(trackByGuid, guid); if (!tr) continue; // stale/deleted GUID — prune - if (restoreTrack(model, guid, tr, tp.flags)) wrote = true; + restoreTrack(model, guid, tr, tp.flags); } // MANAGED LANES: the active mode's lane plays+shows, every other managed lane // is silenced+hidden. Empty on a D1-only project — byte-identical there. const LaneApplyResult lanes = applyLaneOps(trackByGuid, plan.lanes); laneModeChanged = lanes.freeMode; - if (lanes.wrote) wrote = true; // PARENT VISIBILITY (never parked): recomputed every toggle, never recorded. // Only the two visibility flags — never mainSend/FX on a parent. @@ -183,8 +180,8 @@ ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetMod MediaTrack* tr = resolve(trackByGuid, node.guid); if (!tr) continue; // stale GUID — prune const double show = visible.count(node.guid) ? 1.0 : 0.0; - if (writeIfChanged(tr, "B_SHOWINTCP", show)) wrote = true; - if (writeIfChanged(tr, "B_SHOWINMIXER", show)) wrote = true; + writeIfChanged(tr, "B_SHOWINTCP", show); + writeIfChanged(tr, "B_SHOWINMIXER", show); } model.setActiveMode(targetModeId); @@ -225,7 +222,7 @@ ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetMod // an undo rolling the model back underneath it. In memory, never on disk. noteAppliedState(project, model); - return ModeApplyResult{true, wrote}; + return ModeApplyResult{true}; } } // namespace diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 3df9b7b..933d377 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -29,17 +29,16 @@ bool modeApplyInProgress(); // What one applyMode did. // -// `wroteProjectState` counts REAPER writes ONLY — a driven flag, a lane parameter, -// a parent's visibility. Model moves (a snapshot stored, a record's flag half spent, -// the active mode set) are deliberately excluded: they reach disk through -// persistViewState, which every caller runs OUTSIDE its undo block, so no point can -// carry them and counting them would only mint an empty one. It is the answer a -// caller holding its own block needs for the discard decision (design_view_actions' -// ActionUndoBlock); the solo pair cannot affect it, since only a real switch drives -// solo and a real switch mints regardless. +// Deliberately does NOT report whether it wrote project state. It once did, and a +// caller holding its own block used that as its mint-or-discard predicate — which is +// wrong twice over: everything a reapply writes is the four driven flags, which mint +// no undo state at all, so the answer was true for writes REAPER will not hold and the +// resulting label re-titled the Undo button over an entry that was never appended. What +// a membership edit's point actually holds is its `view_state` write; the caller's own +// model diff is what says whether there was one (design_view_actions' +// runMembershipEdit). Do not reintroduce a wrote-project-state return here. struct ModeApplyResult { - bool applied = false; - bool wroteProjectState = false; + bool applied = false; }; // Captures each about-to-park track's prior flag values into `model`, caches/clears diff --git a/src/shell/view/view_lanes.h b/src/shell/view/view_lanes.h index b8adcc6..ca0bdd5 100644 --- a/src/shell/view/view_lanes.h +++ b/src/shell/view/view_lanes.h @@ -15,8 +15,8 @@ class ReaProject; namespace reasampler { // `freeMode` alone decides whether UpdateTimeline is owed (SDK: required after an -// I_FREEMODE change); `wrote` is a REAPER write, the reading ModeApplyResult -// (view.h) carries through to the caller's undo-point decision. +// I_FREEMODE change); `wrote` is a REAPER write and nothing more — not an undo-point +// predicate (these writes mint no undo state), and read by nothing today. struct LaneApplyResult { bool freeMode = false; bool wrote = false; diff --git a/src/shell/view/view_park.h b/src/shell/view/view_park.h index fa639f6..78d0106 100644 --- a/src/shell/view/view_park.h +++ b/src/shell/view/view_park.h @@ -93,13 +93,11 @@ extern const int kViewUndoMask; // Returns whether it wrote. bool writeIfChanged(MediaTrack* tr, const char* parm, double value); -// What one parkTrack call did. `wrote` is a REAPER write — a driven flag moved — -// and nothing else: storing the snapshot is a MODEL move, and the model reaches disk -// outside every undo block, so counting it would mint a point carrying none of it -// (ModeApplyResult, view.h). The two readings part company on exactly one path — a -// RE-PARK whose restore had already handed the flags back at park-shaped values, which -// stores a fresh snapshot and writes no flag — and the REAPER reading is the one that -// wants: nothing in the project moved, so nothing is there for a point to hold. +// What one parkTrack call did. `wrote` is a REAPER write — a driven flag moved — and +// nothing else; storing the snapshot is a MODEL move and does not count. It is NOT an +// undo-point predicate and never was a sound one: the driven flags mint no undo state, +// so no point can hold what this reports (view.h's ModeApplyResult, which used to carry +// it up, no longer does). Nothing reads it today. // `exposed` is true iff this call handed the track back visible instead of parking or // adopting it; the caller (view.cpp) collects exposed tracks across the whole apply for // the one batched console report. From b782fb2731c55bac3e121be996d4296b7090c367 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 9 Aug 2026 12:28:50 -0400 Subject: [PATCH 11/14] Add temporary undo-span tracing for the mid-drain Ctrl-Z bug One switch (REASAMPLER_UNDO_TRACE) gates every line. Traces span open/close reasons, FX writes, UndoBlock depth, timer standoffs, and load-state hooks. --- src/app/CMakeLists.txt | 1 + src/app/main.cpp | 34 +++++++++++++++++---- src/shell/undo/undo_block.cpp | 6 +++- src/shell/undo/undo_trace.cpp | 54 +++++++++++++++++++++++++++++++++ src/shell/undo/undo_trace.h | 29 ++++++++++++++++++ src/shell/view/fx_reclaim.cpp | 56 +++++++++++++++++++++++++++-------- src/shell/view/fx_reclaim.h | 6 +++- src/shell/view/view.cpp | 2 +- 8 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 src/shell/undo/undo_trace.cpp create mode 100644 src/shell/undo/undo_trace.h diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index b340903..79ec27f 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -39,6 +39,7 @@ add_library(reaper_reasampler MODULE ${LICE_SRC} ${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp ${REASAMPLER_SRC_DIR}/shell/undo/undo_block.cpp + ${REASAMPLER_SRC_DIR}/shell/undo/undo_trace.cpp ${REASAMPLER_SRC_DIR}/shell/view/view.cpp ${REASAMPLER_SRC_DIR}/shell/view/fx_reclaim.cpp ${REASAMPLER_SRC_DIR}/shell/view/view_lanes.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index ef62f3a..3c6de88 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -38,6 +38,7 @@ #include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/persist/session.h" // ReaSamplerSession +#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together #include "shell/view/fx_reclaim.h" // the FX reclaimer's tick #include "shell/view/view.h" // applyMode #include "shell/view/view_lanes.h" // reconcileManagedLanes @@ -183,6 +184,12 @@ static void OnTimer() // the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live. if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); + // TEMPORARY: captured in a local (rather than calling GetCurrentProjectInLoadSave() + // twice) so the trace can report the value on every tick, standoff or not, with no + // change to the call below. See undo_trace.h. + ReaProject* const loadSaveProj = GetCurrentProjectInLoadSave(); + REASAMPLER_TRACE("[Timer] loadSaveProj=%p", (void*)loadSaveProj); + // A project load or save is IN FLIGHT, so this tick is running inside its own message // pump — a restore brings plugins online and a plugin load pumps. Everything below // would then act on a half-restored project: poll() reads the pre-load that @@ -192,7 +199,10 @@ static void OnTimer() // the first tick after the load runs as if this one had never been due. Self-lowering // (reaper_plugin_functions.h:1584-1587), and placed after the realtime drive so that // fast path keeps its single-pointer-test shape. See src/shell/view/CLAUDE.md. - if (GetCurrentProjectInLoadSave()) return; + if (loadSaveProj) { + REASAMPLER_TRACE("[Timer] STANDOFF guard=loadSaveProj"); + return; + } // poll()'s undo/redo-reload and project-switch Load branches call // loadFromProject, replacing g_session.view() wholesale — deferred here while @@ -202,8 +212,13 @@ static void OnTimer() // cheap to skip for one tick and resumes correctly, unlatched, on the next. // The reclaim drain is latched for the same reason and is the same window: it // holds that view model for the length of a plugin unload, which pumps. - if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress()) + if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress()) { g_session.poll(); + } else { + REASAMPLER_TRACE("[Timer] SKIP-POLL modeApply=%d drain=%d", + reasampler::modeApplyInProgress() ? 1 : 0, + reasampler::fxReclaimDrainInProgress() ? 1 : 0); + } // persist stays MODEL-ONLY (loads the saved view model but does not apply // visibility, to avoid coupling persist to the view shell); poll() raises a @@ -216,8 +231,15 @@ static void OnTimer() // signal is one-shot, a re-entered applyMode fails closed, and a reapply dropped here // is the saved mode never applied at all. Holding the whole block back one tick also // keeps the panel re-arm ahead of the reapply. - if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress() && - g_session.consumeLoadSignal()) { + // TEMPORARY: split into locals + if/else-if so the skip can be traced without + // calling consumeLoadSignal() (which has a side effect) a second time — same + // short-circuit semantics as the original single `if`. See undo_trace.h. + const bool loadGateModeApply = reasampler::modeApplyInProgress(); + const bool loadGateDraining = reasampler::fxReclaimDrainInProgress(); + if (loadGateModeApply || loadGateDraining) { + REASAMPLER_TRACE("[Timer] SKIP-LOAD modeApply=%d drain=%d", loadGateModeApply ? 1 : 0, + loadGateDraining ? 1 : 0); + } else if (g_session.consumeLoadSignal()) { reasampler::bankPanelNotifyProjectLoaded(); // Reconcile lane ownership against the live project's lanes (P_LANENAME, // the cross-session source of truth) BEFORE reapplying visibility. Never @@ -257,6 +279,8 @@ static void OnTimer() // forked-sibling re-GUID branch requesting a reload here would bypass. static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) { + REASAMPLER_TRACE("[Load] BeginLoadProjectState isUndo=%d", isUndo ? 1 : 0); + // UNBRANCHED, and the reason the terminator is a hook at all: this is REAPER's one // entry for a state load — "project load/undo", plus a new project // (reaper_plugin.h:1203-1205) — and the last moment before the restore. Why that @@ -343,7 +367,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // Same reason, for the reclaimer's undo span: a block left open here has no // owner left to close it and would swallow every edit the user makes after. - reasampler::fxReclaimEndUndoSpan(); + reasampler::fxReclaimEndUndoSpan("extension unload"); g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-projectconfig", (void*)&g_projectConfig); diff --git a/src/shell/undo/undo_block.cpp b/src/shell/undo/undo_block.cpp index 9f10d76..f7ab4b7 100644 --- a/src/shell/undo/undo_block.cpp +++ b/src/shell/undo/undo_block.cpp @@ -5,6 +5,7 @@ #include +#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together #include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — the span this must not nest over #define REAPERAPI_MINIMAL @@ -40,12 +41,13 @@ UndoBlock::UndoBlock(ReaProject* proj) : project_(proj) { // outermost. Roll the depth back on any throw so the type degrades to "this one // block failed to open," not "every block after it is now poisoned." try { - if (g_depth++ == 0) fxReclaimEndUndoSpan(); + if (g_depth++ == 0) fxReclaimEndUndoSpan("UndoBlock ctor"); Undo_BeginBlock2(proj); } catch (...) { --g_depth; throw; } + REASAMPLER_TRACE("[UndoBlock] CTOR proj=%s depth=%d", proj ? "real" : "active", g_depth); } UndoBlock::~UndoBlock() { @@ -63,6 +65,8 @@ UndoBlock::~UndoBlock() { if (!project_ || ValidatePtr2(nullptr, project_, "ReaProject*")) Undo_EndBlock2(project_, label_.c_str(), mask_); --g_depth; + REASAMPLER_TRACE("[UndoBlock] DTOR label=\"%s\" mask=%d depth=%d", + label_.empty() ? "(discard)" : label_.c_str(), mask_, g_depth); } void UndoBlock::recordPoint(std::string label, int mask) { diff --git a/src/shell/undo/undo_trace.cpp b/src/shell/undo/undo_trace.cpp new file mode 100644 index 0000000..59b2156 --- /dev/null +++ b/src/shell/undo/undo_trace.cpp @@ -0,0 +1,54 @@ +// See undo_trace.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). Isolated +// in its own TU so every call site needs only the trace-only header, not the REAPER +// ShowConsoleMsg want-define. + +#include "shell/undo/undo_trace.h" + +#if REASAMPLER_UNDO_TRACE + +#include +#include +#include + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_ShowConsoleMsg +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +unsigned long long g_seq = 0; + +double startMs() { + static const double t0 = std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + return t0; +} + +double nowMs() { + return std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count() - + startMs(); +} + +} // namespace + +void undoTrace(const char* fmt, ...) { + char body[256]; + va_list args; + va_start(args, fmt); + std::vsnprintf(body, sizeof(body), fmt, args); + va_end(args); + + char line[320]; + std::snprintf(line, sizeof(line), "!SHOW:#%llu %.0fms %s\n", ++g_seq, nowMs(), body); + ShowConsoleMsg(line); +} + +} // namespace reasampler + +#endif // REASAMPLER_UNDO_TRACE diff --git a/src/shell/undo/undo_trace.h b/src/shell/undo/undo_trace.h new file mode 100644 index 0000000..844db35 --- /dev/null +++ b/src/shell/undo/undo_trace.h @@ -0,0 +1,29 @@ +#pragma once +// TEMPORARY diagnostic tracing for the Design-View undo-span bug (many-undo-entries-on- +// Ctrl-Z-mid-drain). NOT for shipping. ONE switch below gates every trace line in the +// tree; flip REASAMPLER_UNDO_TRACE to 0, or delete this file pair plus the call sites +// that include it, to revert. +#define REASAMPLER_UNDO_TRACE 1 + +#if REASAMPLER_UNDO_TRACE + +namespace reasampler { + +// Emits one line via ShowConsoleMsg: "UT# ms \n", "!SHOW:"-prefixed so +// a trace point on a background tick never forces the console open. printf-style; +// sequence number and a monotonic millisecond timestamp are stamped here so lines sort +// and date themselves even pasted back out of order. Keep each `fmt` short — this has to +// read at a glance in REAPER's console. +void undoTrace(const char* fmt, ...); + +} // namespace reasampler + +#define REASAMPLER_TRACE(...) ::reasampler::undoTrace(__VA_ARGS__) + +#else + +#define REASAMPLER_TRACE(...) \ + do { \ + } while (0) + +#endif diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index fc6979c..6c14e4c 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -12,6 +12,7 @@ #include "shell/persist/session.h" // flushViewState — the release's durable half #include "shell/undo/undo_block.h" // undoBlockOpen — the reverse of the nesting rule +#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together #include "shell/view/view.h" // modeApplyInProgress, transportBlocksModeSwitch #include "shell/view/view_park.h" // fxGuidString — the one FX-identity read #include "shell/view/view_tracks.h" @@ -46,11 +47,17 @@ UndoSpan g_span; // unconditionally; the close reads the span's values before clearing it, because the // clear has to precede the SDK call — nothing re-entered through Undo_EndBlock2 may // find a span it could close a second time. -void openSpan(ReaProject* proj, const char* label, int mask) { - if (g_span.begin(proj, label, mask)) Undo_BeginBlock2(proj); +// +// `caller`/`reason` are TEMPORARY tracing plumbing only (see shell/undo/undo_trace.h) — +// they select nothing and gate nothing. +void openSpan(ReaProject* proj, const char* label, int mask, const char* caller) { + if (g_span.begin(proj, label, mask)) { + Undo_BeginBlock2(proj); + REASAMPLER_TRACE("[UndoSpan] OPEN caller=%s label=\"%s\" mask=%d", caller, label, mask); + } } -void closeSpan() { +void closeSpan(const char* reason) { if (!g_span.open()) return; ReaProject* const proj = g_span.project(); const std::string label = g_span.label(); @@ -59,6 +66,8 @@ void closeSpan() { // The span outlives its opener, so by here the project may be the one that just // closed — its undo history went with it and there is no block left to end. if (ValidatePtr2(nullptr, proj, "ReaProject*")) Undo_EndBlock2(proj, label.c_str(), mask); + REASAMPLER_TRACE("[UndoSpan] CLOSE reason=%s label=\"%s\" mask=%d", + reason, label.empty() ? "(discard)" : label.c_str(), mask); } // Monotonic and sub-millisecond: the drain's budget discriminates at 30ms, which @@ -171,9 +180,12 @@ struct ReclaimHost { // no-op under a switch's span, which is why the FX bit is added separately — // here, at the first real write, so a switch whose parking never matured closes // on the flags-and-items mask it opened with and serializes no FX state. - openSpan(project, kReclaimUndoLabel, UNDO_STATE_FX); + const bool spanWasOpenBeforeWrite = g_span.open(); + openSpan(project, kReclaimUndoLabel, UNDO_STATE_FX, "drain"); g_span.widen(UNDO_STATE_FX); TrackFX_SetOffline(tr, fxIndex, offline); + REASAMPLER_TRACE("[writeOffline] track=%s fx=%d offline=%d spanWasOpen=%d", + guid.c_str(), fxIndex, offline ? 1 : 0, spanWasOpenBeforeWrite ? 1 : 0); return true; } @@ -241,10 +253,16 @@ bool fxReclaimDrainInProgress() { return g_draining; } void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { // NO guard here may close the span: each names a caller that is still on the stack and // still owns it — the outer drain, or the applyMode that opened it. All re-tick. - if (g_draining) return; + if (g_draining) { + REASAMPLER_TRACE("[Tick] STANDOFF guard=g_draining"); + return; + } // The model is mid-rewrite and its records are being consumed out from under any // plan derived from it. The apply invalidates on its way out, so nothing is lost. - if (modeApplyInProgress()) return; + if (modeApplyInProgress()) { + REASAMPLER_TRACE("[Tick] STANDOFF guard=modeApplyInProgress"); + return; + } // The MIRROR of the rule that no block may open over the span: an extension undo // block is open — a capture, a bank verb, an ingest — and its own message pump is how // this tick arrived. openSpan from in there would begin at depth 2, and that block's @@ -256,7 +274,10 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { // claim is this function's own control-flow order — this TU is REAPER-linked, off the // pure fx_reclaim_tests target, so unlike the queue/span state machine above it, the // ordering is DAW-verified, not unit-tested. - if (undoBlockOpen()) return; + if (undoBlockOpen()) { + REASAMPLER_TRACE("[Tick] STANDOFF guard=undoBlockOpen"); + return; + } DrainLatch latch; // beginTick owns what a pin/project mismatch means. What is local to here: the model @@ -270,7 +291,8 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { // rather than the queue's pin, which moves independently: a tab switch, a closed // project, or no active project at all ends it where it stands rather than carrying // it into another project's history — and the no-project arm below returns. - if (g_span.open() && g_span.project() != project) closeSpan(); + if (g_span.open() && g_span.project() != project) + closeSpan(project ? "project retarget" : "no active project"); if (!g_queue.beginTick(project)) return; // DERIVED, never persisted: recomputed from view state every time the queue was @@ -284,19 +306,26 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { ReclaimHost host{project, &session}; const ReclaimRunResult run = g_queue.run(host); reportDrops(project, run.drops); - if (spanEndsAfter(run)) closeSpan(); + if (spanEndsAfter(run)) { + // Priority order for the trace label only, when more than one is true at once — + // does not affect which branch runs; spanEndsAfter already decided that. + const char* reason = run.aborted ? "drain aborted" + : run.transportDeferred ? "transport-deferred" + : "drain settled"; + closeSpan(reason); + } if (!run.aborted && run.settled) g_queue.goIdle(); } void fxReclaimBeginUndoSpan(ReaProject* proj) { - openSpan(proj, "", 0); + openSpan(proj, "", 0, "action"); } void fxReclaimLabelUndoSpan(const char* label, int mask) { g_span.relabel(label ? label : "", mask); } -void fxReclaimEndUndoSpan() { closeSpan(); } +void fxReclaimEndUndoSpan(const char* reason) { closeSpan(reason); } void fxReclaimAbandonForStateLoad() { // Close FIRST and unconditionally. The abandon below stops the drain and puts the loop @@ -306,7 +335,8 @@ void fxReclaimAbandonForStateLoad() { // confirmed case of that shape on the save side) — the span can never be open at depth // > 0 in the first place: src/shell/view/CLAUDE.md's "NOTHING MAY OPEN AN UNDO BLOCK // WHILE THE SPAN IS OPEN" invariant. closeSpan() just finds nothing to close there. - closeSpan(); + REASAMPLER_TRACE("[abandon] spanOpen=%d", g_span.open() ? 1 : 0); + closeSpan("state load"); g_queue.abandon(); } @@ -322,7 +352,7 @@ void fxReclaimFlushRelease(ReaSamplerSession& session) { // absorb, and the arm is not what decides whether one is open. Same "depth > 0 never // holds an open span" invariant as fxReclaimAbandonForStateLoad above makes this safe // even when the save itself is issued from inside an extension UndoBlock. - closeSpan(); + closeSpan("project save"); if (!g_releaseFlush.pendingFor(saving)) return; // Promptless, unlike design_view_actions' persistViewState, which can open a modal // Save-As. [verify — DAW] whether this write reaches the block of the diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index e131fea..f7fc070 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -698,7 +698,11 @@ void fxReclaimLabelUndoSpan(const char* label, int mask); // unload; and shell/undo's UndoBlock, at its OUTERMOST open. A leaked open block would // swallow everything the user does afterwards, so nothing may return past a terminating // condition without calling this. -void fxReclaimEndUndoSpan(); +// +// `reason` is TEMPORARY tracing plumbing (see shell/undo/undo_trace.h) naming which +// terminating path called — it has no effect on behavior. Revert together with the rest +// of the undo-span tracing pass. +void fxReclaimEndUndoSpan(const char* reason); // EVERY project-state load, undo-driven or not — Ctrl-Z, Ctrl-Shift-Z, a project open, a // File > Revert, a new project. Ends the span, stops any drain on the stack, drops the diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 7a2fe9a..48300cc 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -126,7 +126,7 @@ ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetMod // opens a block ends the span itself, first, at depth 0 — see this directory's // CLAUDE.md. Both calls are idempotent; do not reintroduce either unconditionally. if (realSwitch) { - fxReclaimEndUndoSpan(); + fxReclaimEndUndoSpan("next apply supersedes"); // BEFORE the open, never after this function returns. The wakeup is what // guarantees a tick, and a tick is the only thing that can close this span; a // throw anywhere below would otherwise leave the block open with the reclaimer From e423bf650239834d54eed686a5a1ed41251bb0be Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 9 Aug 2026 13:16:05 -0400 Subject: [PATCH 12/14] Buffer undo trace to a ring, add dump action, stop per-tick loadSaveProj spam Trace lines fill a 4096-line ring instead of hitting the console per line; DUMP_UNDO_TRACE drains it in one ShowConsoleMsg call. Overflow is marked, not silent. --- src/app/main.cpp | 17 +++++++++--- src/shell/undo/undo_trace.cpp | 50 +++++++++++++++++++++++++++++++---- src/shell/undo/undo_trace.h | 20 +++++++++++--- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index 3c6de88..75928cc 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -108,6 +108,9 @@ static void RunShowVersion(int) { // the console window). ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); } +// TEMPORARY: drains the undo-trace ring buffer to the console in one call. See +// undo_trace.h; revert together with the rest of the undo-span tracing pass. +static void RunDumpUndoTrace(int) { reasampler::undoTraceDump(); } // ONE row per bindable action this TU owns: FOREVER-STABLE id suffix, Actions-list // phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload @@ -170,6 +173,9 @@ static std::vector buildMainActionTable() { rows.push_back({"IMPORT_BANK_PACKAGE", "import bank package (.rsbank)", &RunImportBankPackage}); rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion}); + // TEMPORARY: see undo_trace.h; revert together with the rest of the undo-span + // tracing pass. + rows.push_back({"DUMP_UNDO_TRACE", "DIAG: dump undo trace", &RunDumpUndoTrace}); return rows; } @@ -185,10 +191,15 @@ static void OnTimer() if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); // TEMPORARY: captured in a local (rather than calling GetCurrentProjectInLoadSave() - // twice) so the trace can report the value on every tick, standoff or not, with no - // change to the call below. See undo_trace.h. + // twice) so the trace can report it without a second call, with no change to the + // call below. See undo_trace.h. Traced only on CHANGE — every tick reads this + // regardless of state, and the transition is the whole point of the line. ReaProject* const loadSaveProj = GetCurrentProjectInLoadSave(); - REASAMPLER_TRACE("[Timer] loadSaveProj=%p", (void*)loadSaveProj); + static ReaProject* s_tracedLoadSaveProj = nullptr; + if (loadSaveProj != s_tracedLoadSaveProj) { + REASAMPLER_TRACE("[Timer] loadSaveProj=%p", (void*)loadSaveProj); + s_tracedLoadSaveProj = loadSaveProj; + } // A project load or save is IN FLIGHT, so this tick is running inside its own message // pump — a restore brings plugins online and a plugin load pumps. Everything below diff --git a/src/shell/undo/undo_trace.cpp b/src/shell/undo/undo_trace.cpp index 59b2156..6f430b6 100644 --- a/src/shell/undo/undo_trace.cpp +++ b/src/shell/undo/undo_trace.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_ShowConsoleMsg @@ -19,7 +20,21 @@ namespace reasampler { namespace { -unsigned long long g_seq = 0; +// A few thousand lines comfortably outlasts a full mode switch, its background park +// drain, and a mid-drain undo — the exact span the dump has to still hold whole when +// fired well after the fact. Static storage: one allocation for the process, none per +// line. +constexpr std::size_t kCapacity = 4096; +constexpr std::size_t kLineChars = 288; + +struct Line { + char text[kLineChars]; +}; + +Line g_lines[kCapacity]; +std::size_t g_next = 0; // next write slot, wraps +unsigned long long g_totalWritten = 0; // lines written since the last dump/clear +unsigned long long g_seq = 0; double startMs() { static const double t0 = std::chrono::duration( @@ -38,15 +53,40 @@ double nowMs() { } // namespace void undoTrace(const char* fmt, ...) { - char body[256]; + char body[224]; va_list args; va_start(args, fmt); std::vsnprintf(body, sizeof(body), fmt, args); va_end(args); - char line[320]; - std::snprintf(line, sizeof(line), "!SHOW:#%llu %.0fms %s\n", ++g_seq, nowMs(), body); - ShowConsoleMsg(line); + Line& line = g_lines[g_next]; + std::snprintf(line.text, sizeof(line.text), "#%llu %.0fms %s\n", ++g_seq, nowMs(), body); + g_next = (g_next + 1) % kCapacity; + ++g_totalWritten; +} + +void undoTraceDump() { + const bool overflowed = g_totalWritten > kCapacity; + const std::size_t count = + overflowed ? kCapacity : static_cast(g_totalWritten); + const std::size_t oldest = overflowed ? g_next : 0; + + std::string out; + out.reserve(count * 96 + 128); + if (overflowed) { + char marker[128]; + std::snprintf(marker, sizeof(marker), + "[UT OVERFLOW: %llu lines dropped, buffer holds %zu]\n", + g_totalWritten - kCapacity, kCapacity); + out += marker; + } + for (std::size_t i = 0; i < count; ++i) out += g_lines[(oldest + i) % kCapacity].text; + if (out.empty()) out = "[UT] buffer empty\n"; + + ShowConsoleMsg(out.c_str()); + + g_next = 0; + g_totalWritten = 0; } } // namespace reasampler diff --git a/src/shell/undo/undo_trace.h b/src/shell/undo/undo_trace.h index 844db35..faca82e 100644 --- a/src/shell/undo/undo_trace.h +++ b/src/shell/undo/undo_trace.h @@ -3,19 +3,31 @@ // Ctrl-Z-mid-drain). NOT for shipping. ONE switch below gates every trace line in the // tree; flip REASAMPLER_UNDO_TRACE to 0, or delete this file pair plus the call sites // that include it, to revert. +// +// Lines land in an in-memory ring buffer, never straight to the console — a tick-rate +// trace point that called ShowConsoleMsg directly scrolled the console faster than +// Daniel could copy the interesting span. "DIAG: dump undo trace" +// (shell/actions/action_registry, DUMP_UNDO_TRACE) is the one bindable action that +// drains the buffer to the console. #define REASAMPLER_UNDO_TRACE 1 #if REASAMPLER_UNDO_TRACE namespace reasampler { -// Emits one line via ShowConsoleMsg: "UT# ms \n", "!SHOW:"-prefixed so -// a trace point on a background tick never forces the console open. printf-style; +// Appends one line to the ring buffer: "# ms \n". printf-style; // sequence number and a monotonic millisecond timestamp are stamped here so lines sort -// and date themselves even pasted back out of order. Keep each `fmt` short — this has to -// read at a glance in REAPER's console. +// and date themselves however the buffer is later drained. No ShowConsoleMsg call here +// — that is what kept this off the reentrancy risk a tick-rate console write posed. +// Keep each `fmt` short — this has to read at a glance once dumped. void undoTrace(const char* fmt, ...); +// Dumps the whole buffer to the console in ONE ShowConsoleMsg call, oldest line +// first, then clears it. If the buffer wrapped since the last dump, the dump is +// prefixed with a marker naming how many lines were lost rather than dropping them +// silently. +void undoTraceDump(); + } // namespace reasampler #define REASAMPLER_TRACE(...) ::reasampler::undoTrace(__VA_ARGS__) From f6f09d983ca92d8e2b96f4d8a8f523e2903e7988 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 9 Aug 2026 13:40:28 -0400 Subject: [PATCH 13/14] Close the FX reclaim span when REAPER's own Undo/Redo is dispatched, so the switch's point mints first Both command hooks intercept Edit: Undo (40029) and Edit: Redo (40030) and consume nothing. Undo routes that never dispatch an action remain uncovered. --- CLAUDE.md | 2 +- src/app/CMakeLists.txt | 5 +++ src/app/main.cpp | 20 ++++++++-- src/shell/undo/CLAUDE.md | 12 ++++-- src/shell/undo/undo_intercept.cpp | 22 +++++++++++ src/shell/undo/undo_intercept.h | 30 ++++++++++++++ src/shell/view/CLAUDE.md | 55 +++++++++++++++++++++++++- src/shell/view/fx_reclaim.h | 9 +++-- tests/test_undo_intercept.cpp | 65 +++++++++++++++++++++++++++++++ 9 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 src/shell/undo/undo_intercept.cpp create mode 100644 src/shell/undo/undo_intercept.h create mode 100644 tests/test_undo_intercept.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 5af3c14..88d0491 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,7 +188,7 @@ Comments carry *why*, and context where non-obvious — never *what* the code al 2. `rec->Register("gaccel", &accel)` — puts the action in the Actions list. 3. `rec->Register("hookcommand", ...)` — receives every action fired; claim only your own id, return `false` otherwise. 4. On unload (`rec == nullptr`), mirror-unregister everything with the same strings prefixed by `'-'`. -- **Non-main sections use a different mechanism.** `gaccel_register_t` carries no section field — `command_id` + `gaccel` can only ever produce a Main-section action. To publish into another section (Media Explorer = 32063, MIDI editor = 32060, MIDI event list = 32061, MIDI inline = 32062), register a `custom_action_register_t{uniqueSectionId, idStr, name, extra}` under `"custom_action"`; it returns the command id, or **0 on failure** (e.g. a duplicate `idStr`) — which the caller must tolerate rather than half-register. `idStr` must be unique **across all sections**, so an action published into both Main and a non-main section needs a SECOND id string; the FOREVER-STABLE contract binds it identically from the moment it ships. `custom_action_register_t` has no `ACCEL`, so a non-main entry ships no default keybinding. Dispatch for these ids arrives through `"hookcommand2"` (`bool(KbdSectionInfo*, int command, int val, int val2, int relmode, HWND)`) — `"hookcommand"` runs for the main section only. The two hooks must partition the ids between them; what happens when a command is claimed by both is unspecified by the SDK (`hookcommand2`'s doc says a `true` return prevents further hooks/actions from running, which is in tension with a clean double-fire either way), so nothing may rely on either outcome. On unload, mirror with `"-custom_action"` `[verify — DAW]` (the header confirms the `-` prefix for "most" registration types and spells out only `-pcmsrc` by name; `custom_action` itself is unconfirmed) and `"-hookcommand2"`. +- **Non-main sections use a different mechanism.** `gaccel_register_t` carries no section field — `command_id` + `gaccel` can only ever produce a Main-section action. To publish into another section (Media Explorer = 32063, MIDI editor = 32060, MIDI event list = 32061, MIDI inline = 32062), register a `custom_action_register_t{uniqueSectionId, idStr, name, extra}` under `"custom_action"`; it returns the command id, or **0 on failure** (e.g. a duplicate `idStr`) — which the caller must tolerate rather than half-register. `idStr` must be unique **across all sections**, so an action published into both Main and a non-main section needs a SECOND id string; the FOREVER-STABLE contract binds it identically from the moment it ships. `custom_action_register_t` has no `ACCEL`, so a non-main entry ships no default keybinding. Dispatch for these ids arrives through `"hookcommand2"` (`bool(KbdSectionInfo*, int command, int val, int val2, int relmode, HWND)`) — `"hookcommand"` runs for the main section only. `hookcommand2` is NOT the mirror-image "every other section" hook, and reading it that way would miss half the traffic: `reaper_plugin.h:214` says it runs prior to every action triggered by a KEY or MIDI event, in ANY section, main included — so a main-section keystroke can reach both hooks, in an order the SDK does not document. The two hooks must partition the ids between them; what happens when a command is claimed by both is unspecified by the SDK (`hookcommand2`'s doc says a `true` return prevents further hooks/actions from running, which is in tension with a clean double-fire either way), so nothing may rely on either outcome. On unload, mirror with `"-custom_action"` `[verify — DAW]` (the header confirms the `-` prefix for "most" registration types and spells out only `-pcmsrc` by name; `custom_action` itself is unconfirmed) and `"-hookcommand2"`. ## Product design docs diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 79ec27f..a6ccd08 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -39,6 +39,7 @@ add_library(reaper_reasampler MODULE ${LICE_SRC} ${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp ${REASAMPLER_SRC_DIR}/shell/undo/undo_block.cpp + ${REASAMPLER_SRC_DIR}/shell/undo/undo_intercept.cpp ${REASAMPLER_SRC_DIR}/shell/undo/undo_trace.cpp ${REASAMPLER_SRC_DIR}/shell/view/view.cpp ${REASAMPLER_SRC_DIR}/shell/view/fx_reclaim.cpp @@ -78,6 +79,10 @@ reasampler_test(view_reconcile LINK applied_state view_mode_model) # The reclaimer's drain loop is templated on its host for exactly this reason: the # ordering, budget, debounce and resume bookkeeping run against a fake chain. reasampler_test(fx_reclaim LINK fx_state) +# Which dispatched action id ends the span. Header-only and SDK-free, so it links nothing; +# src/shell/undo has no CMakeLists of its own for the same reason src/shell/view has none. +add_executable(undo_intercept_tests ${REASAMPLER_TESTS_DIR}/test_undo_intercept.cpp) +add_test(NAME undo_intercept_tests COMMAND undo_intercept_tests) # Bank-package import: the promptless verb plus its action skin. Kept as its own # appended block rather than merged into the lists above, so the two package diff --git a/src/app/main.cpp b/src/app/main.cpp index 75928cc..40954f8 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -38,6 +38,7 @@ #include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/persist/session.h" // ReaSamplerSession +#include "shell/undo/undo_intercept.h" // REAPER's own Undo/Redo, caught at dispatch #include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together #include "shell/view/fx_reclaim.h" // the FX reclaimer's tick #include "shell/view/view.h" // applyMode @@ -337,6 +338,10 @@ static project_config_extension_t g_projectConfig{ static bool OnHookCommand(int command, int /*flag*/) { if (command == 0) return false; + // FIRST, and ahead of every claim below: this hook runs "prior to every action in the + // main section" (reaper_plugin.h:202), so it is the last moment at which the mode + // switch's point can still be minted before REAPER's own undo runs. + reasampler::endReclaimSpanForUndoCommand(command, 0); if (reasampler::actionTableHandleCommand(command)) return true; if (reasampler::designViewHandleCommand(command)) return true; if (reasampler::bankHandleCommand(command)) return true; @@ -344,13 +349,20 @@ static bool OnHookCommand(int command, int /*flag*/) return false; } -// "hookcommand" covers the main section only, so actions we published into another -// section arrive here instead. Partitioning contract: root `CLAUDE.md` §"REAPER -// extension contract". -static bool OnHookCommand2(KbdSectionInfo* /*sec*/, int command, int /*val*/, int /*val2*/, +// Where the actions we published into a non-main section arrive — "hookcommand" runs for +// the main section only. Partitioning contract: root `CLAUDE.md` §"REAPER extension +// contract". +static bool OnHookCommand2(KbdSectionInfo* sec, int command, int /*val*/, int /*val2*/, int /*relmode*/, HWND /*hwnd*/) { if (command == 0) return false; + // The undo intercept runs from BOTH hooks, because this one is not the non-main + // sections' hook: it runs "prior to every action triggered by a key/MIDI event" + // (reaper_plugin.h:214) in ANY section, main included — and a Ctrl-Z is exactly a + // key event. Which of the two hooks REAPER calls first is undocumented and does not + // matter: the close is idempotent. A null section is left alone rather than assumed to + // be main; OnHookCommand covers the main section unconditionally either way. + reasampler::endReclaimSpanForUndoCommand(command, sec ? sec->uniqueID : -1); return reasampler::ingestHandleSectionCommand(command); } diff --git a/src/shell/undo/CLAUDE.md b/src/shell/undo/CLAUDE.md index a6e4811..9bd952d 100644 --- a/src/shell/undo/CLAUDE.md +++ b/src/shell/undo/CLAUDE.md @@ -2,12 +2,13 @@ ## Scope -One type, `UndoBlock`, and one query, `undoBlockOpen()`. Every extension-owned undo +One type, `UndoBlock`, and one query, `undoBlockOpen()`, plus the interception that ends +the FX reclaim span when REAPER's own Undo/Redo is dispatched. Every extension-owned undo block in the tree is opened through this type; nothing here decides what a point is called or whether one is worth minting — each call site still owns its own label, mask, and discard-vs-mint condition. The FX reclaim span is NOT this directory's: -`shell/view/fx_reclaim` owns it, and this directory only guarantees no block ever -nests over it. +`shell/view/fx_reclaim` owns it, and this directory only guarantees that neither a block +nor REAPER's own undo ever runs nested over it. ## Invariants @@ -48,6 +49,11 @@ nests over it. ## Modules +- `undo_intercept` — the ids of REAPER's own `Edit: Undo`/`Edit: Redo`, the main-section + scoping of that pair, and `endReclaimSpanForUndoCommand`, which `app/main.cpp` calls + from BOTH command hooks before anything else claims the id. Consumes nothing. Why + dispatch and not the state-load hook, which ids are covered, and which undo routes are + NOT: `src/shell/view/CLAUDE.md`, which owns the span. - `undo_block` — `UndoBlock` (RAII open/close plus `recordPoint`) and `undoBlockOpen()`. The latter is read by `fxReclaimTick`, which refuses a tick outright while any block is open: the drain's lazy `openSpan` would otherwise diff --git a/src/shell/undo/undo_intercept.cpp b/src/shell/undo/undo_intercept.cpp new file mode 100644 index 0000000..754233a --- /dev/null +++ b/src/shell/undo/undo_intercept.cpp @@ -0,0 +1,22 @@ +// See undo_intercept.h. Touches no REAPER API of its own: the ids arrive from main.cpp's +// two command hooks, and the only thing done with one is the span's existing close. + +#include "shell/undo/undo_intercept.h" + +#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together +#include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — the ONE idempotent close + +namespace reasampler { + +void endReclaimSpanForUndoCommand(int command, int sectionId) { + if (!isMainUndoRedoCommand(command, sectionId)) return; + // Traced whether or not a span is open, and before the close: the dump has to show the + // hook SEEING the command, which is the half that was missing from the trace of the + // undo that ran under an open block. + REASAMPLER_TRACE("[UndoIntercept] cmd=%d section=%d", command, sectionId); + // Safe at any depth: a span is never open at depth > 0 (src/shell/view/CLAUDE.md), so + // a hook re-entered from inside an extension block finds nothing to close. + fxReclaimEndUndoSpan(command == kEditUndoCommand ? "undo action" : "redo action"); +} + +} // namespace reasampler diff --git a/src/shell/undo/undo_intercept.h b/src/shell/undo/undo_intercept.h new file mode 100644 index 0000000..47635de --- /dev/null +++ b/src/shell/undo/undo_intercept.h @@ -0,0 +1,30 @@ +#pragma once +// REAPER's own Undo/Redo, caught at action dispatch. The reclaim span has to close there +// — minting the mode switch's point — BEFORE REAPER runs the undo: while the span is open +// the switch has no point yet, so Ctrl-Z undoes whatever preceded it, with REAPER's undo +// machinery running nested inside our still-open block. + +namespace reasampler { + +// Main-section "Edit: Undo" / "Edit: Redo". Taken from the vendored SDK's own reference +// plugins, which send exactly these for undo/redo +// (vendor/reaper-sdk/reaper-plugins/reaper_csurf/csurf.h:312-313, used by csurf_01X, +// csurf_alphatrack, csurf_faderport, csurf_mcu) — not read off the action list by eye. +inline constexpr int kEditUndoCommand = 40029; +inline constexpr int kEditRedoCommand = 40030; + +// Command ids are per SECTION (reaper_plugin.h's custom_action_register_t: 0/100 = +// main/main alt, 32060-32063 = the MIDI and media-explorer sections), so the same integer +// arriving under another section is a different action entirely and must not be +// intercepted. What that leaves uncovered is enumerated in src/shell/view/CLAUDE.md. +inline bool isMainUndoRedoCommand(int command, int sectionId) { + if (sectionId != 0 && sectionId != 100) return false; + return command == kEditUndoCommand || command == kEditRedoCommand; +} + +// Ends the reclaim span when `command` is one of the two above, and does nothing +// otherwise. CONSUMES NOTHING: the caller must still return false from its hook and let +// REAPER run the undo, which then executes at depth 0 against a properly formed point. +void endReclaimSpanForUndoCommand(int command, int sectionId); + +} // namespace reasampler diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 4347589..53a8e71 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -118,12 +118,21 @@ decide membership or mode rules. exactly ONE undo entry and returned to the previous mode. (The two-point degradation this once hedged against never materialised; the degradations that DO occur are the transport deferral and `mintManagedLanes`' early close, both named below.) + **Opening the span any LATER than the switch is rejected**, and the ~3s of open block + before the drain's first FX write is not exposure buying nothing. The four driven flags + mint nothing unaided (measured above), so the block's CLOSE is the only thing that ever + makes the switch undoable; open the span at the drain's first write instead and the whole + park hold carries no point for a Ctrl-Z to reach — which is precisely the bug the dispatch + interception below fixes. The dead time is where the switch's point sits waiting to be + minted, which is exactly what the state-load terminator and the dispatch interception + reach into that window to do. - **No path may return past a terminating condition with the span open.** A leaked block swallows every edit the user makes afterwards, indefinitely, so every terminating path routes through ONE idempotent close (`fxReclaimEndUndoSpan`): the drain settling, aborting on a generation bump, or deferring on the transport; the project retargeting or closing; the next REAL SWITCH superseding it; ANY PROJECT-STATE - LOAD; a project save; extension unload; and any path about to open a block of its own + LOAD; REAPER's OWN Undo/Redo caught at action dispatch; a project save; extension + unload; and any path about to open a block of its own (next invariant). The span may stay open only across a wait it can BOUND — the 3s debounce, which is the dead time between the switch and its first FX write, and the only reason the span exists. A TRANSPORT deferral is not bounded @@ -205,6 +214,35 @@ decide membership or mode rules. `abandon`. Daniel's ruling, and the accepted cost: undoing a switch mid-drain leaves a PARTIAL park — some FX offlined, some not — covered by the one point the terminator closed. +- **REAPER's own Undo/Redo is intercepted one step EARLIER than that, at action dispatch — + the load hook is too late for an undo.** DAW-OBSERVED, and the whole reason this exists: + switch to Design view, press Ctrl-Z inside the park hold, and the user gets one undo point + per FX. While the span is open the switch HAS NO POINT YET, so Ctrl-Z does not undo the + switch — it undoes whatever preceded it, with REAPER's undo machinery running nested + inside our still-open block, and the drain then parks against a model that was undone + underneath it. So `main.cpp`'s two command hooks end the span BEFORE REAPER runs the + action (`shell/undo`'s `endReclaimSpanForUndoCommand`): the switch's point mints at that + instant and the undo then executes at depth 0 against a properly formed point. The + interception CONSUMES NOTHING — the hook returns false and REAPER proceeds normally. + The ids are `40029`/`40030`, taken from Cockos' own reference plugins in the vendored SDK + (`vendor/reaper-sdk/reaper-plugins/reaper_csurf/csurf.h:312-313`, sent as `WM_COMMAND` by + four of them) rather than read off the action list by eye; the pair and its section + scoping are pinned by `tests/test_undo_intercept.cpp`. BOTH hooks call it, because + `hookcommand2` is not the non-main sections' hook: it runs prior to every action triggered + by a KEY or MIDI event in ANY section, main included (`reaper_plugin.h:214`), and a + Ctrl-Z is a key event. Which of the two fires first is undocumented and immaterial — the + close is idempotent. + **What this does NOT cover — every one of these still lands inside an open span, exactly + as before:** an undo fired in a NON-MAIN section (the MIDI editor's own Ctrl-Z is a + project undo; its command id lives in that section's own id space and is not verifiable + from the SDK, so nothing is guessed here) — plausible in real use; the Undo History + window's list, which drives the undo directly rather than dispatching `40029` — plausible, + and the likeliest remaining route; and a ReaScript or another extension calling + `Undo_DoUndo2`/`Undo_DoRedo2` (`reaper_plugin_functions.h:7777-7790`) — plausible wherever + the user runs scripts. What IS covered beyond the keystroke: the Edit menu, the toolbar + button, a control surface (the csurf plugins above), an OSC/MIDI binding, and — by + inference from `reaper_plugin.h:206`'s recursion warning rather than by measurement — a + script's `Main_OnCommand(40029)`. - **A state load puts the reclaimer to SLEEP; only a re-read of the model wakes it.** `abandon` clears the wakeup where `invalidate` raises it, because the two say different things: an invalidation says the MODEL moved and owes a re-derivation, a @@ -337,6 +375,21 @@ applies the resulting lane state to live tracks. point — and, since the terminator above, whatever the drain had NOT yet written when the undo arrived, because the span closes at that instant. Whether that reads as correct in the DAW is `[verify — DAW]`, `docs/VERIFICATION.md` §"Mode switching". +- **The trace of the failing mid-hold Ctrl-Z showed NO `BeginLoadProjectState` line at + all**, which the state-load terminator says should fire for an undo + (`reaper_plugin.h:1203-1205`), and the dump had not wrapped. Unexplained: either REAPER + declined the undo outright while our block was open, or it performed one without a state + load. The dispatch interception does not rest on which — it mints the point before REAPER + is asked, either way — but the next dump discriminates, and the expected order is + `[UndoIntercept]`, `[UndoSpan] CLOSE reason=undo action`, then `[Load] + BeginLoadProjectState isUndo=1`. A dump still missing the load line means the diagnosis + needs revisiting, not that the fix regressed. +- **A NATIVE REAPER action fired during the span is absorbed into the switch's point.** + The nesting rule and `UndoBlock` cover every EXTENSION-owned block; REAPER's own actions + cannot be routed through it, so under the depth-counter reading their block closes at + depth 2->1, minting nothing and folding their state into ours. Not fixed here and not + measured — it rests on the same `[verify — DAW]` counter reading as the nesting rule, and + the window is the park hold plus the drain. - **The `view_state` write is NOT inside `applyMode`'s block — but on a REAL switch it is now inside the SPAN.** `persistViewState` (`design_view_actions.cpp`) runs after `applyMode` has returned, and a real switch returns with the span open, so diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index f7fc070..e4a1509 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -694,10 +694,11 @@ void fxReclaimLabelUndoSpan(const char* label, int mask); // The ONE close, idempotent: every terminating path routes through it — the drain // settling, aborting, or deferring on the transport; the project retargeting or closing; -// the next apply superseding it; ANY project-state load; a project save; extension -// unload; and shell/undo's UndoBlock, at its OUTERMOST open. A leaked open block would -// swallow everything the user does afterwards, so nothing may return past a terminating -// condition without calling this. +// the next apply superseding it; ANY project-state load; REAPER's own Undo/Redo at action +// dispatch (shell/undo's endReclaimSpanForUndoCommand, which mints the switch's point +// before REAPER runs the undo); a project save; extension unload; and shell/undo's +// UndoBlock, at its OUTERMOST open. A leaked open block would swallow everything the user +// does afterwards, so nothing may return past a terminating condition without calling this. // // `reason` is TEMPORARY tracing plumbing (see shell/undo/undo_trace.h) naming which // terminating path called — it has no effect on behavior. Revert together with the rest diff --git a/tests/test_undo_intercept.cpp b/tests/test_undo_intercept.cpp new file mode 100644 index 0000000..add5cf5 --- /dev/null +++ b/tests/test_undo_intercept.cpp @@ -0,0 +1,65 @@ +// Standalone tests for which dispatched action ids end the FX reclaim span — no REAPER, +// no test framework. The predicate is the whole of the interception's decision; the close +// it drives needs a DAW. What is assertable here: the id pair is exact, and it is scoped +// to the main sections, because command ids are per-section and the same integer under +// another section is an unrelated action. + +#include "../src/shell/undo/undo_intercept.h" + +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +namespace { + +constexpr int kMainSection = 0; +constexpr int kMainAltSection = 100; +constexpr int kMidiEditor = 32060; +constexpr int kMediaExplorer = 32063; + +void undoAndRedoInTheMainSectionAreIntercepted() { + CHECK(isMainUndoRedoCommand(kEditUndoCommand, kMainSection)); + CHECK(isMainUndoRedoCommand(kEditRedoCommand, kMainSection)); + // Main alt shares the main id space (reaper_plugin.h: "0/100=main/main alt"), so a + // Ctrl-Z fired while the alt section is active is the same action. + CHECK(isMainUndoRedoCommand(kEditUndoCommand, kMainAltSection)); + CHECK(isMainUndoRedoCommand(kEditRedoCommand, kMainAltSection)); +} + +void everyOtherMainSectionActionIsLeftAlone() { + // Every action in the tree passes through this predicate; only these two ids may end + // the span, or an ordinary edit made mid-drain would split the switch's undo point. + CHECK(!isMainUndoRedoCommand(kEditUndoCommand - 1, kMainSection)); + CHECK(!isMainUndoRedoCommand(kEditRedoCommand + 1, kMainSection)); + CHECK(!isMainUndoRedoCommand(40012, kMainSection)); + CHECK(!isMainUndoRedoCommand(0, kMainSection)); + CHECK(!isMainUndoRedoCommand(-1, kMainSection)); +} + +void theSameIdsUnderAnotherSectionAreNotOurUndo() { + CHECK(!isMainUndoRedoCommand(kEditUndoCommand, kMidiEditor)); + CHECK(!isMainUndoRedoCommand(kEditRedoCommand, kMidiEditor)); + CHECK(!isMainUndoRedoCommand(kEditUndoCommand, kMediaExplorer)); + // A hook that could not name its section (null KbdSectionInfo) is not assumed to be + // main — hookcommand covers the main section unconditionally anyway. + CHECK(!isMainUndoRedoCommand(kEditUndoCommand, -1)); +} + +} // namespace + +int main() { + undoAndRedoInTheMainSectionAreIntercepted(); + everyOtherMainSectionActionIsLeftAlone(); + theSameIdsUnderAnotherSectionAreNotOurUndo(); + + if (g_fail) { + std::printf("%d check(s) failed\n", g_fail); + return 1; + } + std::printf("undo_intercept: all checks passed\n"); + return 0; +} From 258b1b6b85964afba6373a0201dc43060bea52f6 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 9 Aug 2026 13:53:56 -0400 Subject: [PATCH 14/14] Remove the temporary undo-span tracing now that the mid-drain Ctrl-Z fix has landed Deletes the ring-buffer tracer and its diagnostic action; reverts the span entry points to their pre-tracing no-parameter signatures. --- src/app/CMakeLists.txt | 1 - src/app/main.cpp | 46 ++------------- src/shell/undo/undo_block.cpp | 6 +- src/shell/undo/undo_intercept.cpp | 7 +-- src/shell/undo/undo_trace.cpp | 94 ------------------------------- src/shell/undo/undo_trace.h | 41 -------------- src/shell/view/CLAUDE.md | 9 --- src/shell/view/fx_reclaim.cpp | 56 +++++------------- src/shell/view/fx_reclaim.h | 6 +- src/shell/view/view.cpp | 2 +- 10 files changed, 22 insertions(+), 246 deletions(-) delete mode 100644 src/shell/undo/undo_trace.cpp delete mode 100644 src/shell/undo/undo_trace.h diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index a6ccd08..d07fb5e 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -40,7 +40,6 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/capture/insert.cpp ${REASAMPLER_SRC_DIR}/shell/undo/undo_block.cpp ${REASAMPLER_SRC_DIR}/shell/undo/undo_intercept.cpp - ${REASAMPLER_SRC_DIR}/shell/undo/undo_trace.cpp ${REASAMPLER_SRC_DIR}/shell/view/view.cpp ${REASAMPLER_SRC_DIR}/shell/view/fx_reclaim.cpp ${REASAMPLER_SRC_DIR}/shell/view/view_lanes.cpp diff --git a/src/app/main.cpp b/src/app/main.cpp index 40954f8..4d7ddb2 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -39,7 +39,6 @@ #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/persist/session.h" // ReaSamplerSession #include "shell/undo/undo_intercept.h" // REAPER's own Undo/Redo, caught at dispatch -#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together #include "shell/view/fx_reclaim.h" // the FX reclaimer's tick #include "shell/view/view.h" // applyMode #include "shell/view/view_lanes.h" // reconcileManagedLanes @@ -109,10 +108,6 @@ static void RunShowVersion(int) { // the console window). ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); } -// TEMPORARY: drains the undo-trace ring buffer to the console in one call. See -// undo_trace.h; revert together with the rest of the undo-span tracing pass. -static void RunDumpUndoTrace(int) { reasampler::undoTraceDump(); } - // ONE row per bindable action this TU owns: FOREVER-STABLE id suffix, Actions-list // phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload // mirror-unregister all iterate this data. The capture scope rows come first, @@ -174,9 +169,6 @@ static std::vector buildMainActionTable() { rows.push_back({"IMPORT_BANK_PACKAGE", "import bank package (.rsbank)", &RunImportBankPackage}); rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion}); - // TEMPORARY: see undo_trace.h; revert together with the rest of the undo-span - // tracing pass. - rows.push_back({"DUMP_UNDO_TRACE", "DIAG: dump undo trace", &RunDumpUndoTrace}); return rows; } @@ -191,17 +183,6 @@ static void OnTimer() // the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live. if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); - // TEMPORARY: captured in a local (rather than calling GetCurrentProjectInLoadSave() - // twice) so the trace can report it without a second call, with no change to the - // call below. See undo_trace.h. Traced only on CHANGE — every tick reads this - // regardless of state, and the transition is the whole point of the line. - ReaProject* const loadSaveProj = GetCurrentProjectInLoadSave(); - static ReaProject* s_tracedLoadSaveProj = nullptr; - if (loadSaveProj != s_tracedLoadSaveProj) { - REASAMPLER_TRACE("[Timer] loadSaveProj=%p", (void*)loadSaveProj); - s_tracedLoadSaveProj = loadSaveProj; - } - // A project load or save is IN FLIGHT, so this tick is running inside its own message // pump — a restore brings plugins online and a plugin load pumps. Everything below // would then act on a half-restored project: poll() reads the pre-load that @@ -211,10 +192,7 @@ static void OnTimer() // the first tick after the load runs as if this one had never been due. Self-lowering // (reaper_plugin_functions.h:1584-1587), and placed after the realtime drive so that // fast path keeps its single-pointer-test shape. See src/shell/view/CLAUDE.md. - if (loadSaveProj) { - REASAMPLER_TRACE("[Timer] STANDOFF guard=loadSaveProj"); - return; - } + if (GetCurrentProjectInLoadSave()) return; // poll()'s undo/redo-reload and project-switch Load branches call // loadFromProject, replacing g_session.view() wholesale — deferred here while @@ -224,13 +202,8 @@ static void OnTimer() // cheap to skip for one tick and resumes correctly, unlatched, on the next. // The reclaim drain is latched for the same reason and is the same window: it // holds that view model for the length of a plugin unload, which pumps. - if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress()) { + if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress()) g_session.poll(); - } else { - REASAMPLER_TRACE("[Timer] SKIP-POLL modeApply=%d drain=%d", - reasampler::modeApplyInProgress() ? 1 : 0, - reasampler::fxReclaimDrainInProgress() ? 1 : 0); - } // persist stays MODEL-ONLY (loads the saved view model but does not apply // visibility, to avoid coupling persist to the view shell); poll() raises a @@ -243,15 +216,8 @@ static void OnTimer() // signal is one-shot, a re-entered applyMode fails closed, and a reapply dropped here // is the saved mode never applied at all. Holding the whole block back one tick also // keeps the panel re-arm ahead of the reapply. - // TEMPORARY: split into locals + if/else-if so the skip can be traced without - // calling consumeLoadSignal() (which has a side effect) a second time — same - // short-circuit semantics as the original single `if`. See undo_trace.h. - const bool loadGateModeApply = reasampler::modeApplyInProgress(); - const bool loadGateDraining = reasampler::fxReclaimDrainInProgress(); - if (loadGateModeApply || loadGateDraining) { - REASAMPLER_TRACE("[Timer] SKIP-LOAD modeApply=%d drain=%d", loadGateModeApply ? 1 : 0, - loadGateDraining ? 1 : 0); - } else if (g_session.consumeLoadSignal()) { + if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress() && + g_session.consumeLoadSignal()) { reasampler::bankPanelNotifyProjectLoaded(); // Reconcile lane ownership against the live project's lanes (P_LANENAME, // the cross-session source of truth) BEFORE reapplying visibility. Never @@ -291,8 +257,6 @@ static void OnTimer() // forked-sibling re-GUID branch requesting a reload here would bypass. static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/) { - REASAMPLER_TRACE("[Load] BeginLoadProjectState isUndo=%d", isUndo ? 1 : 0); - // UNBRANCHED, and the reason the terminator is a hook at all: this is REAPER's one // entry for a state load — "project load/undo", plus a new project // (reaper_plugin.h:1203-1205) — and the last moment before the restore. Why that @@ -390,7 +354,7 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // Same reason, for the reclaimer's undo span: a block left open here has no // owner left to close it and would swallow every edit the user makes after. - reasampler::fxReclaimEndUndoSpan("extension unload"); + reasampler::fxReclaimEndUndoSpan(); g_rec->Register("-timer", (void*)&OnTimer); g_rec->Register("-projectconfig", (void*)&g_projectConfig); diff --git a/src/shell/undo/undo_block.cpp b/src/shell/undo/undo_block.cpp index f7ab4b7..9f10d76 100644 --- a/src/shell/undo/undo_block.cpp +++ b/src/shell/undo/undo_block.cpp @@ -5,7 +5,6 @@ #include -#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together #include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — the span this must not nest over #define REAPERAPI_MINIMAL @@ -41,13 +40,12 @@ UndoBlock::UndoBlock(ReaProject* proj) : project_(proj) { // outermost. Roll the depth back on any throw so the type degrades to "this one // block failed to open," not "every block after it is now poisoned." try { - if (g_depth++ == 0) fxReclaimEndUndoSpan("UndoBlock ctor"); + if (g_depth++ == 0) fxReclaimEndUndoSpan(); Undo_BeginBlock2(proj); } catch (...) { --g_depth; throw; } - REASAMPLER_TRACE("[UndoBlock] CTOR proj=%s depth=%d", proj ? "real" : "active", g_depth); } UndoBlock::~UndoBlock() { @@ -65,8 +63,6 @@ UndoBlock::~UndoBlock() { if (!project_ || ValidatePtr2(nullptr, project_, "ReaProject*")) Undo_EndBlock2(project_, label_.c_str(), mask_); --g_depth; - REASAMPLER_TRACE("[UndoBlock] DTOR label=\"%s\" mask=%d depth=%d", - label_.empty() ? "(discard)" : label_.c_str(), mask_, g_depth); } void UndoBlock::recordPoint(std::string label, int mask) { diff --git a/src/shell/undo/undo_intercept.cpp b/src/shell/undo/undo_intercept.cpp index 754233a..afea981 100644 --- a/src/shell/undo/undo_intercept.cpp +++ b/src/shell/undo/undo_intercept.cpp @@ -3,20 +3,15 @@ #include "shell/undo/undo_intercept.h" -#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together #include "shell/view/fx_reclaim.h" // fxReclaimEndUndoSpan — the ONE idempotent close namespace reasampler { void endReclaimSpanForUndoCommand(int command, int sectionId) { if (!isMainUndoRedoCommand(command, sectionId)) return; - // Traced whether or not a span is open, and before the close: the dump has to show the - // hook SEEING the command, which is the half that was missing from the trace of the - // undo that ran under an open block. - REASAMPLER_TRACE("[UndoIntercept] cmd=%d section=%d", command, sectionId); // Safe at any depth: a span is never open at depth > 0 (src/shell/view/CLAUDE.md), so // a hook re-entered from inside an extension block finds nothing to close. - fxReclaimEndUndoSpan(command == kEditUndoCommand ? "undo action" : "redo action"); + fxReclaimEndUndoSpan(); } } // namespace reasampler diff --git a/src/shell/undo/undo_trace.cpp b/src/shell/undo/undo_trace.cpp deleted file mode 100644 index 6f430b6..0000000 --- a/src/shell/undo/undo_trace.cpp +++ /dev/null @@ -1,94 +0,0 @@ -// See undo_trace.h. Compiled into the reaper_reasampler module; includes -// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). Isolated -// in its own TU so every call site needs only the trace-only header, not the REAPER -// ShowConsoleMsg want-define. - -#include "shell/undo/undo_trace.h" - -#if REASAMPLER_UNDO_TRACE - -#include -#include -#include -#include - -#define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_ShowConsoleMsg -#include "reaper_plugin_functions.h" - -namespace reasampler { - -namespace { - -// A few thousand lines comfortably outlasts a full mode switch, its background park -// drain, and a mid-drain undo — the exact span the dump has to still hold whole when -// fired well after the fact. Static storage: one allocation for the process, none per -// line. -constexpr std::size_t kCapacity = 4096; -constexpr std::size_t kLineChars = 288; - -struct Line { - char text[kLineChars]; -}; - -Line g_lines[kCapacity]; -std::size_t g_next = 0; // next write slot, wraps -unsigned long long g_totalWritten = 0; // lines written since the last dump/clear -unsigned long long g_seq = 0; - -double startMs() { - static const double t0 = std::chrono::duration( - std::chrono::steady_clock::now().time_since_epoch()) - .count(); - return t0; -} - -double nowMs() { - return std::chrono::duration( - std::chrono::steady_clock::now().time_since_epoch()) - .count() - - startMs(); -} - -} // namespace - -void undoTrace(const char* fmt, ...) { - char body[224]; - va_list args; - va_start(args, fmt); - std::vsnprintf(body, sizeof(body), fmt, args); - va_end(args); - - Line& line = g_lines[g_next]; - std::snprintf(line.text, sizeof(line.text), "#%llu %.0fms %s\n", ++g_seq, nowMs(), body); - g_next = (g_next + 1) % kCapacity; - ++g_totalWritten; -} - -void undoTraceDump() { - const bool overflowed = g_totalWritten > kCapacity; - const std::size_t count = - overflowed ? kCapacity : static_cast(g_totalWritten); - const std::size_t oldest = overflowed ? g_next : 0; - - std::string out; - out.reserve(count * 96 + 128); - if (overflowed) { - char marker[128]; - std::snprintf(marker, sizeof(marker), - "[UT OVERFLOW: %llu lines dropped, buffer holds %zu]\n", - g_totalWritten - kCapacity, kCapacity); - out += marker; - } - for (std::size_t i = 0; i < count; ++i) out += g_lines[(oldest + i) % kCapacity].text; - if (out.empty()) out = "[UT] buffer empty\n"; - - ShowConsoleMsg(out.c_str()); - - g_next = 0; - g_totalWritten = 0; -} - -} // namespace reasampler - -#endif // REASAMPLER_UNDO_TRACE diff --git a/src/shell/undo/undo_trace.h b/src/shell/undo/undo_trace.h deleted file mode 100644 index faca82e..0000000 --- a/src/shell/undo/undo_trace.h +++ /dev/null @@ -1,41 +0,0 @@ -#pragma once -// TEMPORARY diagnostic tracing for the Design-View undo-span bug (many-undo-entries-on- -// Ctrl-Z-mid-drain). NOT for shipping. ONE switch below gates every trace line in the -// tree; flip REASAMPLER_UNDO_TRACE to 0, or delete this file pair plus the call sites -// that include it, to revert. -// -// Lines land in an in-memory ring buffer, never straight to the console — a tick-rate -// trace point that called ShowConsoleMsg directly scrolled the console faster than -// Daniel could copy the interesting span. "DIAG: dump undo trace" -// (shell/actions/action_registry, DUMP_UNDO_TRACE) is the one bindable action that -// drains the buffer to the console. -#define REASAMPLER_UNDO_TRACE 1 - -#if REASAMPLER_UNDO_TRACE - -namespace reasampler { - -// Appends one line to the ring buffer: "# ms \n". printf-style; -// sequence number and a monotonic millisecond timestamp are stamped here so lines sort -// and date themselves however the buffer is later drained. No ShowConsoleMsg call here -// — that is what kept this off the reentrancy risk a tick-rate console write posed. -// Keep each `fmt` short — this has to read at a glance once dumped. -void undoTrace(const char* fmt, ...); - -// Dumps the whole buffer to the console in ONE ShowConsoleMsg call, oldest line -// first, then clears it. If the buffer wrapped since the last dump, the dump is -// prefixed with a marker naming how many lines were lost rather than dropping them -// silently. -void undoTraceDump(); - -} // namespace reasampler - -#define REASAMPLER_TRACE(...) ::reasampler::undoTrace(__VA_ARGS__) - -#else - -#define REASAMPLER_TRACE(...) \ - do { \ - } while (0) - -#endif diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index 53a8e71..ba2b982 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -375,15 +375,6 @@ applies the resulting lane state to live tracks. point — and, since the terminator above, whatever the drain had NOT yet written when the undo arrived, because the span closes at that instant. Whether that reads as correct in the DAW is `[verify — DAW]`, `docs/VERIFICATION.md` §"Mode switching". -- **The trace of the failing mid-hold Ctrl-Z showed NO `BeginLoadProjectState` line at - all**, which the state-load terminator says should fire for an undo - (`reaper_plugin.h:1203-1205`), and the dump had not wrapped. Unexplained: either REAPER - declined the undo outright while our block was open, or it performed one without a state - load. The dispatch interception does not rest on which — it mints the point before REAPER - is asked, either way — but the next dump discriminates, and the expected order is - `[UndoIntercept]`, `[UndoSpan] CLOSE reason=undo action`, then `[Load] - BeginLoadProjectState isUndo=1`. A dump still missing the load line means the diagnosis - needs revisiting, not that the fix regressed. - **A NATIVE REAPER action fired during the span is absorbed into the switch's point.** The nesting rule and `UndoBlock` cover every EXTENSION-owned block; REAPER's own actions cannot be routed through it, so under the depth-counter reading their block closes at diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp index 6c14e4c..fc6979c 100644 --- a/src/shell/view/fx_reclaim.cpp +++ b/src/shell/view/fx_reclaim.cpp @@ -12,7 +12,6 @@ #include "shell/persist/session.h" // flushViewState — the release's durable half #include "shell/undo/undo_block.h" // undoBlockOpen — the reverse of the nesting rule -#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together #include "shell/view/view.h" // modeApplyInProgress, transportBlocksModeSwitch #include "shell/view/view_park.h" // fxGuidString — the one FX-identity read #include "shell/view/view_tracks.h" @@ -47,17 +46,11 @@ UndoSpan g_span; // unconditionally; the close reads the span's values before clearing it, because the // clear has to precede the SDK call — nothing re-entered through Undo_EndBlock2 may // find a span it could close a second time. -// -// `caller`/`reason` are TEMPORARY tracing plumbing only (see shell/undo/undo_trace.h) — -// they select nothing and gate nothing. -void openSpan(ReaProject* proj, const char* label, int mask, const char* caller) { - if (g_span.begin(proj, label, mask)) { - Undo_BeginBlock2(proj); - REASAMPLER_TRACE("[UndoSpan] OPEN caller=%s label=\"%s\" mask=%d", caller, label, mask); - } +void openSpan(ReaProject* proj, const char* label, int mask) { + if (g_span.begin(proj, label, mask)) Undo_BeginBlock2(proj); } -void closeSpan(const char* reason) { +void closeSpan() { if (!g_span.open()) return; ReaProject* const proj = g_span.project(); const std::string label = g_span.label(); @@ -66,8 +59,6 @@ void closeSpan(const char* reason) { // The span outlives its opener, so by here the project may be the one that just // closed — its undo history went with it and there is no block left to end. if (ValidatePtr2(nullptr, proj, "ReaProject*")) Undo_EndBlock2(proj, label.c_str(), mask); - REASAMPLER_TRACE("[UndoSpan] CLOSE reason=%s label=\"%s\" mask=%d", - reason, label.empty() ? "(discard)" : label.c_str(), mask); } // Monotonic and sub-millisecond: the drain's budget discriminates at 30ms, which @@ -180,12 +171,9 @@ struct ReclaimHost { // no-op under a switch's span, which is why the FX bit is added separately — // here, at the first real write, so a switch whose parking never matured closes // on the flags-and-items mask it opened with and serializes no FX state. - const bool spanWasOpenBeforeWrite = g_span.open(); - openSpan(project, kReclaimUndoLabel, UNDO_STATE_FX, "drain"); + openSpan(project, kReclaimUndoLabel, UNDO_STATE_FX); g_span.widen(UNDO_STATE_FX); TrackFX_SetOffline(tr, fxIndex, offline); - REASAMPLER_TRACE("[writeOffline] track=%s fx=%d offline=%d spanWasOpen=%d", - guid.c_str(), fxIndex, offline ? 1 : 0, spanWasOpenBeforeWrite ? 1 : 0); return true; } @@ -253,16 +241,10 @@ bool fxReclaimDrainInProgress() { return g_draining; } void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { // NO guard here may close the span: each names a caller that is still on the stack and // still owns it — the outer drain, or the applyMode that opened it. All re-tick. - if (g_draining) { - REASAMPLER_TRACE("[Tick] STANDOFF guard=g_draining"); - return; - } + if (g_draining) return; // The model is mid-rewrite and its records are being consumed out from under any // plan derived from it. The apply invalidates on its way out, so nothing is lost. - if (modeApplyInProgress()) { - REASAMPLER_TRACE("[Tick] STANDOFF guard=modeApplyInProgress"); - return; - } + if (modeApplyInProgress()) return; // The MIRROR of the rule that no block may open over the span: an extension undo // block is open — a capture, a bank verb, an ingest — and its own message pump is how // this tick arrived. openSpan from in there would begin at depth 2, and that block's @@ -274,10 +256,7 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { // claim is this function's own control-flow order — this TU is REAPER-linked, off the // pure fx_reclaim_tests target, so unlike the queue/span state machine above it, the // ordering is DAW-verified, not unit-tested. - if (undoBlockOpen()) { - REASAMPLER_TRACE("[Tick] STANDOFF guard=undoBlockOpen"); - return; - } + if (undoBlockOpen()) return; DrainLatch latch; // beginTick owns what a pin/project mismatch means. What is local to here: the model @@ -291,8 +270,7 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { // rather than the queue's pin, which moves independently: a tab switch, a closed // project, or no active project at all ends it where it stands rather than carrying // it into another project's history — and the no-project arm below returns. - if (g_span.open() && g_span.project() != project) - closeSpan(project ? "project retarget" : "no active project"); + if (g_span.open() && g_span.project() != project) closeSpan(); if (!g_queue.beginTick(project)) return; // DERIVED, never persisted: recomputed from view state every time the queue was @@ -306,26 +284,19 @@ void fxReclaimTick(ReaSamplerSession& session, ReaProject* proj) { ReclaimHost host{project, &session}; const ReclaimRunResult run = g_queue.run(host); reportDrops(project, run.drops); - if (spanEndsAfter(run)) { - // Priority order for the trace label only, when more than one is true at once — - // does not affect which branch runs; spanEndsAfter already decided that. - const char* reason = run.aborted ? "drain aborted" - : run.transportDeferred ? "transport-deferred" - : "drain settled"; - closeSpan(reason); - } + if (spanEndsAfter(run)) closeSpan(); if (!run.aborted && run.settled) g_queue.goIdle(); } void fxReclaimBeginUndoSpan(ReaProject* proj) { - openSpan(proj, "", 0, "action"); + openSpan(proj, "", 0); } void fxReclaimLabelUndoSpan(const char* label, int mask) { g_span.relabel(label ? label : "", mask); } -void fxReclaimEndUndoSpan(const char* reason) { closeSpan(reason); } +void fxReclaimEndUndoSpan() { closeSpan(); } void fxReclaimAbandonForStateLoad() { // Close FIRST and unconditionally. The abandon below stops the drain and puts the loop @@ -335,8 +306,7 @@ void fxReclaimAbandonForStateLoad() { // confirmed case of that shape on the save side) — the span can never be open at depth // > 0 in the first place: src/shell/view/CLAUDE.md's "NOTHING MAY OPEN AN UNDO BLOCK // WHILE THE SPAN IS OPEN" invariant. closeSpan() just finds nothing to close there. - REASAMPLER_TRACE("[abandon] spanOpen=%d", g_span.open() ? 1 : 0); - closeSpan("state load"); + closeSpan(); g_queue.abandon(); } @@ -352,7 +322,7 @@ void fxReclaimFlushRelease(ReaSamplerSession& session) { // absorb, and the arm is not what decides whether one is open. Same "depth > 0 never // holds an open span" invariant as fxReclaimAbandonForStateLoad above makes this safe // even when the save itself is issued from inside an extension UndoBlock. - closeSpan("project save"); + closeSpan(); if (!g_releaseFlush.pendingFor(saving)) return; // Promptless, unlike design_view_actions' persistViewState, which can open a modal // Save-As. [verify — DAW] whether this write reaches the block of the diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h index e4a1509..753a3fd 100644 --- a/src/shell/view/fx_reclaim.h +++ b/src/shell/view/fx_reclaim.h @@ -699,11 +699,7 @@ void fxReclaimLabelUndoSpan(const char* label, int mask); // before REAPER runs the undo); a project save; extension unload; and shell/undo's // UndoBlock, at its OUTERMOST open. A leaked open block would swallow everything the user // does afterwards, so nothing may return past a terminating condition without calling this. -// -// `reason` is TEMPORARY tracing plumbing (see shell/undo/undo_trace.h) naming which -// terminating path called — it has no effect on behavior. Revert together with the rest -// of the undo-span tracing pass. -void fxReclaimEndUndoSpan(const char* reason); +void fxReclaimEndUndoSpan(); // EVERY project-state load, undo-driven or not — Ctrl-Z, Ctrl-Shift-Z, a project open, a // File > Revert, a new project. Ends the span, stops any drain on the stack, drops the diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index 48300cc..7a2fe9a 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -126,7 +126,7 @@ ModeApplyResult applyModeImpl(ViewModeModel& model, const std::string& targetMod // opens a block ends the span itself, first, at depth 0 — see this directory's // CLAUDE.md. Both calls are idempotent; do not reintroduce either unconditionally. if (realSwitch) { - fxReclaimEndUndoSpan("next apply supersedes"); + fxReclaimEndUndoSpan(); // BEFORE the open, never after this function returns. The wakeup is what // guarantees a tick, and a tick is the only thing that can close this span; a // throw anywhere below would otherwise leave the block open with the reclaimer