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.
This commit is contained in:
2026-08-09 13:40:28 -04:00
parent e423bf6502
commit f6f09d983c
9 changed files with 207 additions and 13 deletions
+54 -1
View File
@@ -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