diff --git a/CLAUDE.md b/CLAUDE.md index 8b4f9bc..88d0491 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 @@ -187,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/docs/TODO.md b/docs/TODO.md index ffe79f7..4b2596a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -867,9 +867,9 @@ public accessor surface. Doing that in the same commit that changed the byte for the golden test literals pin would roll a format change and a codec extraction together, which is the riskier order. -**`src/shell/view/view.cpp` is no longer over the bar.** It measures **594 lines** +**`src/shell/view/view.cpp` is no longer over the bar.** It measures **230 lines** (`wc -l`, re-measured after the undo collapse moved one track's whole park/restore — -snapshot, flags and per-FX offline — into `view_fx_park`'s `parkTrack`/ +snapshot, flags and per-FX offline — into `view_park`'s `parkTrack`/ `restoreTrack`). Recorded because the entry above used to name it as a second over-ceiling file: if it grows again, note that a further seam there is blocked not by a private-state/friend question but by a build file another team owns — @@ -885,7 +885,7 @@ sidestepped), dropping the file under the ~600-line ceiling. ## FX-GUID stability for the park's restore is unverified in the DAW **Context.** The Design View park/restore FX keying (`restoreTrack`, -`src/shell/view/view_fx_park.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that +`src/shell/view/view_park.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that survives a chain reorder while a track is parked. SWS issue #802 reports that after `SNM_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin (`SNM_PreObjectState()` → `RemoveAllIds()`) — if that still holds, an SWS-driven @@ -896,7 +896,7 @@ operation this keying targets. instances of the same plugin. **Already flagged in code — this entry is the tracked home, not a restatement.** -There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view_fx_park.cpp` and +There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view_park.cpp` and a note in `src/shell/view/CLAUDE.md`'s Gotchas; point at them rather than restating them in full. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 161f2df..22dbcb5 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -47,15 +47,18 @@ Checks for Θ, Ξ, Ψ, Ε, Ρ, Γ, and Ω work that no unit test can close. Buil - [ ] Solo tracks in Arrange, switch to Design, solo different tracks, switch back — each mode restores its own solo set verbatim (`docs/COMPLETED.md` §"Ψ-W1-T2") - [ ] Attempt a mode switch while the transport is playing, then while recording — both refuse, visibly (`docs/COMPLETED.md` §"Ψ-W1-T2") - [ ] Click the footer mode segment, save, reopen the project — the mode persisted (`docs/COMPLETED.md` §"Ψ-W1-T2") -- [ ] `[verify — DAW]` Reproduce the strand: project with FX in both modes, switch to Design, save, close, reopen, toggle to Arrange. Toggle back and forth several more times — the arrangement's FX must NOT converge on permanently offline/hidden. A track the tool refuses to park is NAMED in the console, and the same refusal is not reprinted on a later reapply unless the refused set changed (`src/shell/view/view_fx_park.h`'s `decidePark` / `reportRefusedParks`) -- [ ] `[verify — DAW]` **ONE switch, ONE point.** In a project with several FX across several tracks, switch modes once and count the undo points REAPER shows (Ctrl-Z tooltip / undo history): expect exactly ONE, `ReaSampler: activate view` — never two, and never one per FX. Then press Ctrl-Z once: the flags unpark and EVERY FX comes back online, in that single step. Verify the EFFECT, not merely that one entry exists — a mask that failed to pick up `UNDO_STATE_FX` would still show one correct-looking entry while leaving every plugin offline, which reads as a pass and isn't. Then Ctrl-Y: the redo re-parks all of it, also in one step (`src/shell/view/CLAUDE.md` §Invariants — "ONE mode switch is ONE undo point") +- [ ] `[verify — DAW]` Reproduce the strand: a track with no park record whose live chain already reads park-shaped (hand-offline a plugin, or drop the tool's record via undo/redo), then switch modes — the track is driven visible, in-mix and FX-enabled (not parked), and NAMED in the console once. Switch again with nothing else changed and confirm the same message does NOT reprint (`src/shell/view/view_park.h`'s `parkTrack` / `reportExposedTracks` / `shouldReport`) +- [ ] `[verify — DAW]` **ONE switch, ONE point.** In a project with several FX across several tracks, switch modes once and count the undo points REAPER shows (Ctrl-Z tooltip / undo history): expect exactly ONE, `ReaSampler: activate view` — never two, and never one per FX. Then press Ctrl-Z once: the flags and lane state unpark in that single step — FX are the reclaimer's and are not part of this undo point (see the item below on undo and FX). Then Ctrl-Y: the redo re-parks the flags and lane state, also in one step (`src/shell/view/CLAUDE.md` §Invariants — "ONE mode switch is ONE undo point") +- [x] **VERIFIED — the discard-block mint-suppression premise.** Ten unblocked `TrackFX_SetOffline` calls minted ten undo points; the same ten calls wrapped in `Undo_BeginBlock2(p)` … `Undo_EndBlock2(p, "", 0)` minted zero. This is what the reclaimer's per-write discard block (`fx_reclaim.cpp`'s `writeOffline`) and the off-switch FX design both rest on — no longer an unobserved inference (`src/shell/view/CLAUDE.md` §Invariants — "ONE mode switch is ONE undo point") +- [ ] `[verify — DAW]` Whether a `SetProjExtState` issued from inside `SaveExtensionConfig` reaches the `` block of the file being written now, or lands one save later (`src/shell/view/fx_reclaim.h`'s `ReleaseFlush`, `fx_reclaim.cpp`'s `fxReclaimFlushRelease`) +- [ ] `[verify — DAW]` Whether an undo (Ctrl-Z) of a mode switch now restores visibility correctly, now that FX offline/online moved off `applyMode`'s block onto the reclaimer — a Ctrl-Z rolls back only the flags and lane state, not whatever the reclaimer had (or had not) converged (`src/shell/view/CLAUDE.md` §Gotchas — "An UNDO of a mode switch no longer rolls back FX at all") - [ ] `[verify — DAW]` **Where the `view_state` lands on undo AND redo, and whether the redo strands a track.** `persistViewState` writes the ext state AFTER `applyMode`'s block closes, so the point never saw the new `view_state` — the undo direction is expected to come out right by position, the redo direction is the open one. Use a project with FX on the tracks that park. After the Ctrl-Z above, read the footer segment: it must show the mode the user came FROM, and a further switch must bank solos under that mode id. Then Ctrl-Y and read it again: if the tracks re-park while the footer still shows the pre-switch mode, the redo restored the stale ext state and model-vs-project is out of step. **Then switch modes ONCE MORE and read the console** — the hypothesised consequence is a `left unparked` refusal line naming those tracks, because the redo-restored model carries no snapshot for a chain that reads parked. A clean switch with no refusal line kills the hypothesis; the line appearing confirms a NEW refusal route and the persist position has to be reopened. Report which, and the footer state at each step. Do NOT fix by moving `persistViewState` inside the block — it can open a modal Save-As (`src/shell/view/CLAUDE.md` §Gotchas) - [ ] `[verify — DAW]` **No point on open.** Open a project saved in Design mode with every inactive leaf already fully parked (flags AND FX), and check the undo history: the load-tick reapply writes nothing, so it must add NO undo point at all — the `("", 0)` discard form. This only holds when the saved state really was fully parked; a first open after tagging a new leaf, an FX added to a parked track, or a plugin hand-onlined since the last save all have real state to write and legitimately mint one correct point. An entry appearing on a project that WAS fully parked means the discard is not working — and since `mintManagedLanes`' no-op path rests on the same idiom, check that alongside it (`src/shell/view/view.h`'s `applyMintsUndoPoint`) -- [ ] `[verify — DAW]` **The hitch now sits on the switch.** On a project with heavy plugins (convolution, a loaded sampler) across several parked tracks, time the mode toggle: by design the action does not return until every plugin has unloaded/reloaded. Report roughly how long, and whether the UI recovers cleanly afterwards — the whole write phase runs under one `PreventUIRefresh(1)` hold (`src/shell/view/CLAUDE.md` §Invariants — the documented caveat) -- [ ] `[verify — DAW]` **Nested blocks around the FX writes.** Two directions, both undocumented in the SDK header. (a) `render_in_place` calls `applyMode` inside its OWN undo block, so its single `ReaSampler: render selected track to a new track` point should now absorb the reapply's FX moves — run that verb from Design mode over a track with FX and confirm ONE point, not two, and that Ctrl-Z reverses all of it. (b) The inverse: fire a mode-switch action WHILE a switch's plugin loads are in flight (a heavy chain widens the window) — `applyMode` is latched, so the expected outcome is that the second switch is DROPPED entirely and silently; the history must show one point, and the footer must still read the first switch's target. Re-firing it afterwards must work normally. (c) The same window, but let `OnTimer` -> `bankPanelRefresh` -> `mintManagedLanes` run into it: that path is NOT latched and opens a block of its own, so check the history for a split or misordered point (`src/shell/view/CLAUDE.md` §Gotchas) +- [ ] `[verify — DAW]` **The switch itself returns fast; the hitch moved to the reclaimer.** On a project with heavy plugins (convolution, a loaded sampler) across several parked tracks, time the mode toggle action itself — it no longer waits on any plugin load/unload, so it should return near-instantly. Then watch the background reclaim: once a track has read continuously parked for `kParkHoldMs` (3s), the reclaimer moves it in budgeted ticks (`kOnlineBudgetMs`/`kOfflineBudgetMs`) — report whether those ticks are noticeable/disruptive to the UI while they run (`src/shell/view/CLAUDE.md` §Invariants — the documented caveat, `fx_reclaim.h`'s `kOnlineBudgetMs`/`kOfflineBudgetMs`) +- [ ] `[verify — DAW]` **Nested blocks around the flag/lane writes.** Two directions, both undocumented in the SDK header (`applyMode` no longer writes FX at all — that moved to the reclaimer, off this block entirely). (a) `render_in_place` calls `applyMode` inside its OWN undo block, so its single `ReaSampler: render selected track to a new track` point should absorb the reapply's flag/lane writes — run that verb from Design mode over a track with FX and confirm ONE point, not two, and that Ctrl-Z reverses all of it. (b) The inverse: fire a mode-switch action WHILE a switch's plugin loads are in flight (a heavy chain widens the window) — `applyMode` is latched, so the expected outcome is that the second switch is DROPPED entirely and silently; the history must show one point, and the footer must still read the first switch's target. Re-firing it afterwards must work normally. (c) The same window, but let `OnTimer` -> `bankPanelRefresh` -> `mintManagedLanes` run into it: that path is NOT latched and opens a block of its own, so check the history for a split or misordered point (`src/shell/view/CLAUDE.md` §Gotchas) - [ ] `[verify — DAW]` **The load reapply is never lost to the latch.** Open a project saved in Design mode with heavy plugins on parked tracks (so an apply is long) while another apply can overlap — e.g. trigger a project open from an action fired during a switch. The saved mode must still be applied: the load glue tests `modeApplyInProgress` before spending its one-shot signal and retries on the next timer tick, so a delayed-by-a-tick reapply is a pass and a never-applied one is a failure (`src/app/main.cpp`'s load glue, `src/shell/view/view.h`'s `modeApplyInProgress`) - [ ] `[verify — DAW]` **A tab switch mid-apply cannot split the block.** `applyMode` pins the project once at entry and threads it through `Undo_BeginBlock2`/`Undo_EndBlock2`/`ValidatePtr2`. With a long switch in flight, switch project tabs (the pump premise makes this reachable) and confirm the undo point lands on the project the switch started in, with nothing written into the other tab (`src/shell/view/view.cpp`'s `applyMode`) -- [ ] `[verify — DAW]` **A→B→A costs two full cycles.** Toggle Arrange→Design→Arrange quickly on a project with heavy plugins: every plugin unloads and reloads twice, where the retired intent queue made a fast double-toggle free. Confirm the END STATE is still correct (every FX online, every driven flag back at its captured value) and report whether the doubled cost is tolerable in practice (`src/shell/view/CLAUDE.md` §Invariants — the A→B→A bullet) +- [ ] `[verify — DAW]` **A fast A→B→A moves zero plugins; a held one costs a cycle each way.** Toggle Arrange→Design→Arrange quickly (well inside `kParkHoldMs`, 3s) on a project with heavy plugins — the debounce should mean nothing ever unloads: confirm the END STATE is correct (every FX online, every driven flag back at its captured value) and that the reclaimer reports no writes. Then repeat holding each side past 3s before flipping back and confirm the reclaimer now does converge each leg (offline while parked, online again on return), reporting whether that cost is tolerable in practice (`src/shell/view/CLAUDE.md` §Invariants — "A rapid A→B→A flip moves zero plugins", `fx_reclaim.h`'s `kParkHoldMs`) - [ ] `[verify — DAW]` For a track the console names as left unparked, follow the message's recovery IN FULL: re-enable its TCP/mixer visibility, main send and FX enable, **and set every FX in its chain online** (FX enable is the chain bypass — it does not bring an individually offlined FX back). Then switch modes twice: it parks and restores normally, with no further console line. Do the flag half ONLY on a second such track and confirm it is still refused — the FX half of the trust test, and the reason the message spells the step out (unit-tested only as a decision, never against a live chain) ## Actions and drops diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 3512a0d..d07fb5e 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -38,9 +38,15 @@ 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/undo/undo_intercept.cpp ${REASAMPLER_SRC_DIR}/shell/view/view.cpp - ${REASAMPLER_SRC_DIR}/shell/view/view_fx_park.cpp + ${REASAMPLER_SRC_DIR}/shell/view/fx_reclaim.cpp + ${REASAMPLER_SRC_DIR}/shell/view/view_lanes.cpp + ${REASAMPLER_SRC_DIR}/shell/view/view_park.cpp + ${REASAMPLER_SRC_DIR}/shell/view/view_reconcile.cpp ${REASAMPLER_SRC_DIR}/shell/view/view_solo.cpp + ${REASAMPLER_SRC_DIR}/shell/view/view_tracks.cpp ${REASAMPLER_SRC_DIR}/shell/capture/track_guid.cpp ${REASAMPLER_SRC_DIR}/shell/capture/provenance_shell.cpp ${REASAMPLER_SRC_DIR}/shell/capture/item_read.cpp @@ -56,20 +62,26 @@ add_library(reaper_reasampler MODULE ${REASAMPLER_SRC_DIR}/shell/actions/insert_fx_action.cpp ${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp ) -target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers) +target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree fx_state applied_state guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable insert_fx_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers) # NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The # instrument renders its own bake in its own process, which is what keeps the extension's # link graph free of the voice engine — a link edge to it here means the design drifted. target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) -# The park surface's decisions are pure (header-inline, no REAPER types), so they are -# CTest-covered like a core/ module: the snapshot trust test, the refusal message and -# its once-per-changed-set gate, and the apply's undo-point fold. Declared here rather -# than in a src/shell/view/CMakeLists.txt because that directory deliberately has none -# — its TUs are compiled into this target directly. view_mode_model is linked for -# makeParkPlan, the flag set the trust test folds over; pinning that against hand-built -# ops would not catch a change to the planner. -reasampler_test(view_fx_park LINK fx_offline view_mode_model) +# The view shell's decisions are pure (header-inline, no REAPER types), so they are +# CTest-covered like a core/ module: the Flag -> parameter mapping, the apply's +# undo-point fold, and the reconcile's scope rule and drift trigger. Declared here +# rather than in a src/shell/view/CMakeLists.txt because that directory deliberately +# has none — its TUs are compiled into this target directly. +reasampler_test(view_park LINK view_mode_model) +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 a851741..4d7ddb2 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -38,7 +38,11 @@ #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/view/view.h" // reconcileManagedLanes / applyMode +#include "shell/undo/undo_intercept.h" // REAPER's own Undo/Redo, caught at dispatch +#include "shell/view/fx_reclaim.h" // the FX reclaimer's tick +#include "shell/view/view.h" // applyMode +#include "shell/view/view_lanes.h" // reconcileManagedLanes +#include "shell/view/view_reconcile.h" // reconcileAppliedFlags namespace capture = reasampler::capture; @@ -104,7 +108,6 @@ static void RunShowVersion(int) { // the console window). ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str()); } - // 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, @@ -180,13 +183,27 @@ 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 // this timer mid-apply), same retry-next-tick shape as the consume guard below. // Deferred WHOLE, not just the reload branches: poll()'s identity tracking is // cheap to skip for one tick and resumes correctly, unlatched, on the next. - if (!reasampler::modeApplyInProgress()) g_session.poll(); + // 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()) + g_session.poll(); // 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 @@ -195,19 +212,34 @@ static void OnTimer() // signal re-arms the bank panel's new-content detector — notified BEFORE the // reapply so re-arm and model restore ride the one load event (otherwise // pre-existing tracks can be mis-detected as "new" and mass-tagged). - // modeApplyInProgress is tested BEFORE the consume, not after the applyMode call: - // the load 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() && g_session.consumeLoadSignal()) { + // Both latches are tested BEFORE the consume, not after the applyMode call: the load + // 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()) { reasampler::bankPanelNotifyProjectLoaded(); // Reconcile lane ownership against the live project's lanes (P_LANENAME, // the cross-session source of truth) BEFORE reapplying visibility. Never // re-mints, never mass-tags. reasampler::reconcileManagedLanes(g_session.view(), nullptr); + // An undo/redo reload also arrives here, and REAPER's undo does not restore + // the visibility flags a park drove (UNDO_STATE_TRACKCFG does not carry them). + // Repair the tracks the reloaded model no longer records BEFORE the reapply, + // which only ever drives tracks the model still records. + reasampler::reconcileAppliedFlags(g_session.view(), nullptr); reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); } + // Converge the FX chains toward what the view state says they should be — the + // 1897ms of TrackFX_SetOffline a mode switch no longer pays synchronously. Same + // idle discipline as the realtime fast path above: ONE bool read when there is + // nothing to converge. Ordered AFTER the load block deliberately — a reload replaces + // the view model wholesale, and the applyMode above is what re-derives the queue + // from it, so draining first would spend a tick against the outgoing model. + if (reasampler::fxReclaimPending()) + reasampler::fxReclaimTick(g_session, nullptr); + reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed } @@ -220,27 +252,40 @@ 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) g_session.requestReload(); } -// Intentional no-ops: ReaSampler stores state via project EXT STATE, not this -// extension's own project lines. The struct is registered ONLY for the -// BeginLoadProjectState undo/redo notification. +// Intentional no-op: ReaSampler stores state via project EXT STATE, not this +// extension's own project lines. static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/, bool /*isUndo*/, project_config_extension_t* /*reg*/) { return false; // we own no project lines — ext state carries our data } -static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool /*isUndo*/, +// We write no lines into `ctx` — this is the FX reclaimer's one durability point. Its +// spent park records ride the user's save rather than dirtying the project on the +// reclaimer's own initiative (fx_reclaim.h's ReleaseFlush). +// +// A save only: an isUndo pass is REAPER capturing an undo point, not writing the file, +// and clearing the pending flush on one would leave the record unspent on disk for good. +static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool isUndo, project_config_extension_t* /*reg*/) { + if (!isUndo) + reasampler::fxReclaimFlushRelease(g_session); } // Storage must outlive registration — REAPER holds this pointer until we unregister it. @@ -257,6 +302,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; @@ -264,13 +313,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); } @@ -296,6 +352,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/core/view/CLAUDE.md b/src/core/view/CLAUDE.md index 11f4533..f719733 100644 --- a/src/core/view/CLAUDE.md +++ b/src/core/view/CLAUDE.md @@ -45,6 +45,11 @@ settled 2026-07-23): `ViewModeModel::reconcile(liveGuids)`). The same rule binds one level down: a parked track's per-FX offline state keys on the FX's own identity (`TrackFX_GetFXGUID`), never its slot — see `fx_offline`. +- **A park record's two halves release independently.** The flag half is spent + the moment a restore runs (`releaseSnapshotFlags`); the FX half survives until + the reclaimer has actually driven that chain back online (`releaseSnapshotFx`) + — releasing both at restore would destroy the only record of which plugins the + user had offline before the park, and a later online would blanket-enable them. - **Relative/portable state only** in the persisted view section (GUID strings, mode ids — no absolute paths, no index positions). - **Show-both semantics.** A per-track "pin visible across modes" flag that @@ -103,6 +108,8 @@ settled 2026-07-23): - `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch. - `guid_diff` — the pure, REAPER-free core of the D2 Wave-2 new-content detection: `newGuids(previous, current)` computes the GUIDs present in `current` but absent from `previous` (empty GUIDs ignored); `GuidBaseline` tracks the live GUID set across polls for one project, implementing the first-poll-after-open guard (the first `observe()` after construction/`reset()` records a baseline and reports nothing new, so pre-existing content is never mass-tagged) and re-arms via `reset()` on a detected project switch so detection never diffs across two unrelated projects. - `lane_keys` — the pure, REAPER-free convention mapping a fixed lane's durable REAPER name (`P_LANENAME:n`) to the opaque lane-key `view_mode_model` keys by, plus the managed/manual heuristic both the toggle-apply path and the new-content/auto-tag exemption path share: `kManagedLanePrefix` ("reasampler:") stamps every lane the tool mints; `isManagedLaneName`/`managedLaneKey`/`laneNameForMode`/`modeIdFromLaneName` round-trip a lane name ↔ its owning mode id; `isOnManualLane` is the single predicate governing which lanes a toggle may drive and which items are exempt from auto-tag. +- `fx_state` — what the FX chains SHOULD be, derived from view state alone: `desiredFxState` (the whole project's Online/Offline work, Online sorted first), `fxRestoreTargets` (one track's Online targets from its park record), and `resolveFxWork` (one item's move resolved by identity against a live chain). Derived and never persisted; the park record's captured FX half is the only durable half. +- `applied_state` — a comparable fingerprint (`AppliedState`/`appliedState`) of what the tool's last apply actually drove, so a reconcile can ask "did our output drift from what a restore would drive it back to" rather than "did an undo happen" — membership, lane ownership and the solo cache are deliberately excluded. ## Gotchas diff --git a/src/core/view/CMakeLists.txt b/src/core/view/CMakeLists.txt index 8d8df40..5364578 100644 --- a/src/core/view/CMakeLists.txt +++ b/src/core/view/CMakeLists.txt @@ -19,6 +19,12 @@ reasampler_test(view_mode_model LINK view_mode_model) reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model) reasampler_test(view_tree LINK view_tree) +reasampler_pure_library(fx_state SOURCES fx_state.cpp LINK PUBLIC view_mode_model) +reasampler_test(fx_state LINK fx_state) + +reasampler_pure_library(applied_state SOURCES applied_state.cpp LINK PUBLIC view_mode_model) +reasampler_test(applied_state LINK applied_state) + reasampler_pure_library(guid_diff SOURCES guid_diff.cpp) reasampler_test(guid_diff LINK guid_diff) diff --git a/src/core/view/applied_state.cpp b/src/core/view/applied_state.cpp new file mode 100644 index 0000000..624a508 --- /dev/null +++ b/src/core/view/applied_state.cpp @@ -0,0 +1,16 @@ +#include "core/view/applied_state.h" + +namespace reasampler::view { + +AppliedState appliedState(const ViewModeModel& model) { + AppliedState out; + out.activeModeId = model.activeModeId(); + for (const auto& [guid, snap] : model.snapshots()) { // std::map ⇒ GUID order + if (!snap.flagsHeld) continue; + out.parked.push_back( + AppliedTrack{guid, snap.showInTcp, snap.showInMixer, snap.mainSend, snap.fxEnable}); + } + return out; +} + +} // namespace reasampler::view diff --git a/src/core/view/applied_state.h b/src/core/view/applied_state.h new file mode 100644 index 0000000..21221ae --- /dev/null +++ b/src/core/view/applied_state.h @@ -0,0 +1,55 @@ +#pragma once +// A comparable fingerprint of what an apply put into the project, so a reconcile +// can ask "did our applied output drift from what a restore would drive it back +// to?" rather than "did an undo happen?". The second question is not the same +// one: an undo that touched nothing we drive still answers yes, and repairing on +// it re-enables state the user disabled by hand. Narrower still than "did the +// model change?": membership, lane ownership and the solo cache are excluded by +// design (see AppliedState below), so an edit to any of those changes what a +// future apply must drive while leaving this fingerprint bit-identical. Not a +// sole reapply trigger for anything membership can affect. + +#include +#include + +#include "core/view/view_mode_model.h" + +namespace reasampler::view { + +// One track the tool currently holds parked, and the flag values a restore would +// drive it back to. Per-FX offline is deliberately absent: it is derived, never +// persisted and never in an undo record, so a difference there is evidence of +// nothing about the model. +struct AppliedTrack { + std::string guid; + int showInTcp = 0; + int showInMixer = 0; + int mainSend = 0; + int fxEnable = 0; + + bool operator==(const AppliedTrack& o) const { + return guid == o.guid && showInTcp == o.showInTcp && + showInMixer == o.showInMixer && mainSend == o.mainSend && + fxEnable == o.fxEnable; + } + bool operator!=(const AppliedTrack& o) const { return !(*this == o); } +}; + +// Membership edits, lane ownership and the solo cache are all deliberately out: +// they change constantly without changing what the apply drove, and folding them +// in would put a reconcile back to firing on every unrelated edit. +struct AppliedState { + std::string activeModeId; + std::vector parked; // GUID order + + bool operator==(const AppliedState& o) const { + return activeModeId == o.activeModeId && parked == o.parked; + } + bool operator!=(const AppliedState& o) const { return !(*this == o); } +}; + +// A track appears iff its park record still HOLDS its flag half — that is +// precisely the set the tool is currently driving away from the user's values. +AppliedState appliedState(const ViewModeModel& model); + +} // namespace reasampler::view diff --git a/src/core/view/fx_state.cpp b/src/core/view/fx_state.cpp new file mode 100644 index 0000000..e28b202 --- /dev/null +++ b/src/core/view/fx_state.cpp @@ -0,0 +1,71 @@ +#include "core/view/fx_state.h" + +#include +#include + +namespace reasampler::view { + +namespace { + +// Slot keying, deliberately: the live chain IS the target set, so its positions +// are current by construction. Identity keying would drop an FX REAPER reports no +// GUID for, and reclaim has to reach those too. +std::vector offlineTargets(const std::string& trackGuid, + const std::vector& liveFxGuids) { + std::vector ops; + ops.reserve(liveFxGuids.size()); + for (std::size_t i = 0; i < liveFxGuids.size(); ++i) + ops.push_back(FxOfflineOp{trackGuid, FxKeying::Slot, liveFxGuids[i], + static_cast(i), true}); + return ops; +} + +} // namespace + +std::vector fxRestoreTargets(const ViewModeModel& model, + const std::string& trackGuid) { + std::vector ops; + if (!model.holdsSnapshotFx(trackGuid)) return ops; + + const TrackSnapshot* snap = model.snapshot(trackGuid); + ops.reserve(snap->fxOffline.size()); + for (std::size_t i = 0; i < snap->fxOffline.size(); ++i) { + const FxOfflineState& fx = snap->fxOffline[i]; + ops.push_back(FxOfflineOp{trackGuid, snap->fxKeying, fx.fxGuid, + static_cast(i), fx.offline != 0}); + } + return ops; +} + +std::vector desiredFxState(const ViewModeModel& model, + const std::string& targetMode, + const std::set& liveTrackGuids) { + std::vector online; + std::vector offline; + + for (const auto& [guid, snap] : model.snapshots()) { // std::map ⇒ GUID order + if (liveTrackGuids.count(guid) == 0) continue; // dead track — nothing to write + + if (!model.leafBelongsToMode(guid, targetMode)) { + offline.push_back(FxTrackWork{guid, FxWorkKind::Offline, {}}); + continue; + } + // Active with the FX half already released: the chain is back online and + // the record is only waiting on its flags. Nothing owed either way. + if (!snap.fxHeld) continue; + online.push_back(FxTrackWork{guid, FxWorkKind::Online, fxRestoreTargets(model, guid)}); + } + + online.reserve(online.size() + offline.size()); + for (FxTrackWork& w : offline) online.push_back(std::move(w)); + return online; +} + +FxRestoreResolution resolveFxWork(const FxTrackWork& work, + const std::vector& liveFxGuids) { + if (work.kind == FxWorkKind::Offline) + return resolveFxRestore(offlineTargets(work.trackGuid, liveFxGuids), liveFxGuids); + return resolveFxRestore(work.targets, liveFxGuids); +} + +} // namespace reasampler::view diff --git a/src/core/view/fx_state.h b/src/core/view/fx_state.h new file mode 100644 index 0000000..b804ef1 --- /dev/null +++ b/src/core/view/fx_state.h @@ -0,0 +1,74 @@ +#pragma once +// What the FX chains SHOULD be, derived from view state: an ordered per-track +// work list plus its resolution against a live chain. Derived, never persisted +// and never in an undo record — the park record's captured FX half is the only +// persisted half. Pure: FX identities are opaque strings the shell reads from +// REAPER (TrackFX_GetFXGUID) and hands in. + +#include +#include +#include + +#include "core/view/fx_offline.h" +#include "core/view/view_mode_model.h" + +namespace reasampler::view { + +enum class FxWorkKind { + Online, // back to the park record's captured per-FX state, verbatim + Offline, // reclaim: every live FX offline +}; + +// One track's whole FX move, and the ATOMIC unit of work: a drain may stop +// between items, never inside one, so an item is either untouched or complete +// and no partial state survives a tick boundary. Do not reintroduce a +// within-track resume cursor — carrying one across ticks meant reconciling it +// against a chain the user may have edited in between, and the case an FX with +// no reported identity forced (a cursor that reads as "nothing done yet" and +// re-walks the item forever) was a live non-termination hazard. +struct FxTrackWork { + std::string trackGuid; + FxWorkKind kind = FxWorkKind::Offline; + + // Online: the captured targets, verbatim — never a blanket "online", which is + // exactly what would lose the plugins the USER had offline before the park. + // Offline: empty. The live chain is the target set, and only the shell can + // read it, so resolveFxWork synthesises the targets from what it is handed. + std::vector targets; + + bool operator==(const FxTrackWork& o) const { + return trackGuid == o.trackGuid && kind == o.kind && targets == o.targets; + } +}; + +// The whole project's FX work, from view state alone — no folder tree, because a +// park record is only ever written for a track the planner already decided to +// park (parents and show-both leaves never get one). +// +// ONLINE WORK SORTS FIRST: onlining restores state the user owns, offlining is +// opportunistic reclaim that may be deferred indefinitely without losing +// anything. Within a kind, GUID order — deterministic, so a drain's position in +// the list survives a re-derivation. +// +// `targetMode` is explicit, mirroring planToggle, rather than read from +// `model.activeModeId()`: applyMode sets the active mode LAST, after its park +// and restore loops, so a caller reading it from inside or before that block +// would classify every track against the OUTGOING mode instead. +std::vector desiredFxState(const ViewModeModel& model, + const std::string& targetMode, + const std::set& liveTrackGuids); + +// One track's Online targets from its park record's FX half; empty when the +// record holds none. Each target carries the record's keying, so a snapshot +// lifted from a pre-identity project still restores by slot. +std::vector fxRestoreTargets(const ViewModeModel& model, + const std::string& trackGuid); + +// Resolves one work item against the chain as it stands, where `liveFxGuids[i]` +// is the identity of the FX at slot `i` right now (empty when REAPER reported +// none) — the item's WHOLE move, matched by identity: an identity that is not +// live is DROPPED, never re-pointed at a slot. +FxRestoreResolution resolveFxWork(const FxTrackWork& work, + const std::vector& liveFxGuids); + +} // namespace reasampler::view diff --git a/src/core/view/view_mode_model.cpp b/src/core/view/view_mode_model.cpp index d7ce350..1e2ea88 100644 --- a/src/core/view/view_mode_model.cpp +++ b/src/core/view/view_mode_model.cpp @@ -188,7 +188,7 @@ LaneMintPlan planLaneMinting(const ViewModeModel& model, const FolderTree& tree, return plan; } -TrackPlan makeParkPlan(const std::string& guid, int fxCount) { +TrackPlan makeParkPlan(const std::string& guid) { TrackPlan p; p.flags = { {guid, Flag::ShowInTcp, 0}, @@ -196,13 +196,6 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount) { {guid, Flag::MainSend, 0}, {guid, Flag::FxEnable, 0}, }; - // Park has no identity question to answer: it offlines every slot that is - // live right now, so slot keying IS the addressing. In production fxCount - // is always 0 here — planToggle calls this with fxCount=0 and the D2 shell - // expands the real writes itself via TrackFX_GetCount (parkFxOffline in - // shell/view/view.cpp); a nonzero fxCount only exercises this loop in tests. - for (int i = 0; i < fxCount; ++i) - p.fxOffline.push_back(FxOfflineOp{guid, FxKeying::Slot, {}, i, true}); return p; } @@ -214,11 +207,6 @@ TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) { {guid, Flag::MainSend, snap.mainSend}, {guid, Flag::FxEnable, snap.fxEnable}, }; - for (std::size_t i = 0; i < snap.fxOffline.size(); ++i) { - const FxOfflineState& fx = snap.fxOffline[i]; - p.fxOffline.push_back(FxOfflineOp{guid, snap.fxKeying, fx.fxGuid, - static_cast(i), fx.offline != 0}); - } return p; } @@ -253,6 +241,49 @@ const TrackSnapshot* ViewModeModel::snapshot(const std::string& guid) const { return it == snapshots_.end() ? nullptr : &it->second; } +bool ViewModeModel::holdsSnapshotFlags(const std::string& guid) const { + const TrackSnapshot* s = snapshot(guid); + return s && s->flagsHeld; +} + +bool ViewModeModel::holdsSnapshotFx(const std::string& guid) const { + const TrackSnapshot* s = snapshot(guid); + return s && s->fxHeld; +} + +namespace { + +// A record with neither half left records nothing — keeping it would make +// snapshot() answer yes to a track the model no longer describes. +void eraseIfSpent(std::map& snapshots, + std::map::iterator it) { + if (!it->second.flagsHeld && !it->second.fxHeld) snapshots.erase(it); +} + +} // namespace + +void ViewModeModel::releaseSnapshotFlags(const std::string& guid) { + auto it = snapshots_.find(guid); + if (it == snapshots_.end()) return; + it->second.flagsHeld = false; + it->second.showInTcp = 0; + it->second.showInMixer = 0; + it->second.mainSend = 0; + it->second.fxEnable = 0; + eraseIfSpent(snapshots_, it); +} + +void ViewModeModel::releaseSnapshotFx(const std::string& guid) { + auto it = snapshots_.find(guid); + if (it == snapshots_.end()) return; + it->second.fxHeld = false; + it->second.fxOffline.clear(); + // fxKeying is left as captured: the record's own keying, not a claim about + // one it never had. Harmless today (fxOffline is empty right above), but + // whoever reads fxKeying after this point should see the truth. + eraseIfSpent(snapshots_, it); +} + std::size_t ViewModeModel::reconcile(const std::set& liveGuids) { // See header: snapshots are pruned, membership is not (undo-delete rationale). soloCache_.reconcile(liveGuids); @@ -322,11 +353,12 @@ TogglePlan ViewModeModel::planToggle(const FolderTree& tree, const std::string& const bool active = leafBelongsToMode(guid, targetMode); if (active) { - if (const TrackSnapshot* snap = snapshot(guid)) - plan.restore.push_back(makeRestorePlan(guid, *snap)); + // holdsSnapshotFlags, not snapshot(): a record whose FX half is still + // draining carries zeroed flags, and restoring THOSE would re-park it. + if (holdsSnapshotFlags(guid)) + plan.restore.push_back(makeRestorePlan(guid, *snapshot(guid))); } else { - // fxOffline is empty here; the D2 shell expands it via TrackFX_GetCount. - plan.park.push_back(makeParkPlan(guid, /*fxCount=*/0)); + plan.park.push_back(makeParkPlan(guid)); } } @@ -370,11 +402,15 @@ std::string intToStr(int v) { return json::numToStr(v); } using ObjWriter = json::Writer; // Version ladder for the stored blob, under the FOREVER-STABLE "view_state" key. -// Only the per-FX snapshot shape has ever moved: // // v1 "snapshots":[{...,"fxOffline":[0,1,0]}] — offline state by SLOT // v2 "snapshots":[{...,"fxOffline":[0,1,0], — the v1 array, still written // "fx":[{"guid":"{..}","offline":0},...]}] — by FX IDENTITY +// v3 adds "flagsHeld"/"fxHeld", each written ONLY when false. A record's two +// halves are released independently, and absent-means-held keeps every v1/v2 +// blob (and every fully-held v3 record) byte-identical to what v2 wrote. A v2 +// reader sees a half-released record as fully held, which restores flags it +// should not — the same leniency direction the version field itself takes. // // v2 writes BOTH: "fx" is what this build reads, and the v1 array is what a build // that predates identity keying reads — a downgrade keeps exactly the behavior it @@ -387,7 +423,7 @@ using ObjWriter = json::Writer; // view_state falls back to a default model, which loses every membership tag, so // leniency is the safe direction here — the opposite call from origin_ledger, // where a misread blob would put prune's deletion authority on bad data. -constexpr int kViewStateVersion = 2; +constexpr int kViewStateVersion = 3; } // namespace @@ -439,7 +475,7 @@ std::string ViewModeModel::serialize() const { out += ']'; // snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, - // fxOffline[], fx[] } — see the version ladder above. + // fxOffline[], flagsHeld?, fxHeld?, fx[] } — see the version ladder above. root.keyBegin("snapshots"); out += '['; { @@ -460,6 +496,10 @@ std::string ViewModeModel::serialize() const { e.keyBegin("fxOffline"); writeIntArray(out, bySlot); + // Absent means held — see the version ladder above. + if (!snap.flagsHeld) e.keyRaw("flagsHeld", "false"); + if (!snap.fxHeld) e.keyRaw("fxHeld", "false"); + // A Slot-keyed snapshot has no identities to write — emitting an // "fx" array for it would invent the very keys it lacks. if (snap.fxKeying == FxKeying::Identity) { @@ -634,6 +674,8 @@ bool parseSnapshots(json::Reader& r, std::map& snaps else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; } else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; } else if (k == "fxOffline") { if (!r.parseIntArray(bySlot)) return false; haveSlot = true; } + else if (k == "flagsHeld") { if (!r.parseBool(snap.flagsHeld)) return false; } + else if (k == "fxHeld") { if (!r.parseBool(snap.fxHeld)) return false; } else if (k == "fx") { if (!parseFxStates(r, byIdentity)) return false; haveIdentity = true; diff --git a/src/core/view/view_mode_model.h b/src/core/view/view_mode_model.h index b35d6ac..75013dd 100644 --- a/src/core/view/view_mode_model.h +++ b/src/core/view/view_mode_model.h @@ -196,6 +196,11 @@ struct FolderTree { // parking — restore's source of truth. Ints, not bools, so a snapshot // faithfully round-trips whatever REAPER reported (defensive against // non-0/1 values). +// +// The two halves are released INDEPENDENTLY. The flag half is spent the moment a +// restore writes it back; the FX half has to outlive that, because it is the only +// record of which plugins the USER had offline before the park — release it with +// the flag half and a later online blanket-enables them. struct TrackSnapshot { int showInTcp = 0; // B_SHOWINTCP prior value int showInMixer = 0; // B_SHOWINMIXER prior value @@ -208,15 +213,19 @@ struct TrackSnapshot { std::vector fxOffline; FxKeying fxKeying = FxKeying::Identity; + bool flagsHeld = true; // false ⇒ a restore already wrote the flags back + bool fxHeld = true; // false ⇒ the chain is back online; nothing left to restore + bool operator==(const TrackSnapshot& o) const { return showInTcp == o.showInTcp && showInMixer == o.showInMixer && mainSend == o.mainSend && fxEnable == o.fxEnable && - fxOffline == o.fxOffline && fxKeying == o.fxKeying; + fxOffline == o.fxOffline && fxKeying == o.fxKeying && + flagsHeld == o.flagsHeld && fxHeld == o.fxHeld; } }; -// Which scalar flag a TrackFlagOp drives. FX-offline is carried separately (it -// is per-slot, variable length) — see TrackParkPlan::fxOffline. +// Which scalar flag a TrackFlagOp drives. FX-offline is not one of these and is +// not planned beside them — it is derived from view state (core/view/fx_state). enum class Flag { ShowInTcp, // B_SHOWINTCP ShowInMixer, // B_SHOWINMIXER @@ -249,14 +258,12 @@ struct LanePlayOp { } }; -// The complete set of operations to park one inactive leaf, or restore one -// leaf. Park uses fixed zeros; restore uses a snapshot's values. Park's -// fxOffline ops are slot-keyed (every live slot goes offline); restore's carry -// the snapshot's keying and are resolved against the live chain by -// resolveFxRestore before any write. +// One leaf's FLAG-DOMAIN plan: park writes fixed zeros, restore writes the +// snapshot's captured values. Per-FX offline is deliberately absent — it is a +// derived function of view state (core/view/fx_state), not a peer of the flags, +// and carrying it here only ever produced an always-empty park-side vector. struct TrackPlan { - std::vector flags; - std::vector fxOffline; + std::vector flags; }; // The plan for a toggle to a target mode. Parents and show-both leaves never @@ -295,11 +302,27 @@ public: bool setActiveMode(const std::string& modeId); // The shell calls store before it parks a track, so restore survives a save. + // Both halves are held from here; each is released by the act that spends it. void storeSnapshot(const std::string& guid, const TrackSnapshot& snap); - void clearSnapshot(const std::string& guid); + void clearSnapshot(const std::string& guid); // both halves at once const TrackSnapshot* snapshot(const std::string& guid) const; const std::map& snapshots() const { return snapshots_; } + // A held half is one nothing has spent yet. `snapshot()` returning non-null is + // NOT that question: a record outlives its flag half, so a caller that reads + // the flag values off a record without asking here will drive stale ones. + bool holdsSnapshotFlags(const std::string& guid) const; + bool holdsSnapshotFx(const std::string& guid) const; + + // Spent by the restore that writes the captured flags back. Zeroes them, so a + // record that no longer claims them cannot be read for them either. + void releaseSnapshotFlags(const std::string& guid); + + // Spent only once the chain is ACTUALLY back online — not by the flag restore. + // Releasing both together destroys the only record of which plugins the user + // had offline before the park, and a later online then blanket-enables them. + void releaseSnapshotFx(const std::string& guid); + // Drops every snapshot whose GUID is NOT in `liveGuids`, and prunes the solo // cache the same way (see SoloCache::reconcile). Returns the count of // SNAPSHOTS removed — the solo cache's own count is available from it directly. @@ -330,10 +353,7 @@ public: // restored from it. Parents and show-both leaves are never parked. // Untagged leaves are Arrange members and park/restore accordingly. Tree // membership is the enumeration source, so stale membership GUIDs absent - // from the tree are ignored. - // - // Park plans here carry an empty fxOffline vector — the D2 shell expands - // per-FX offline writes via TrackFX_GetCount (not available to the pure model). + // from the tree are ignored. Flag domain only; FX comes from desiredFxState. TogglePlan planToggle(const FolderTree& tree, const std::string& targetMode) const; // Managed lanes only, from the ownership index — the set a toggle may @@ -357,11 +377,11 @@ private: view::SoloCache soloCache_; // modeId -> guid -> raw I_SOLO }; -// Fixed-zero park plan for one leaf, offlining `fxCount` slots. -TrackPlan makeParkPlan(const std::string& guid, int fxCount); +// Fixed-zero park plan for one leaf. +TrackPlan makeParkPlan(const std::string& guid); // Restore plan for one leaf from its snapshot — every flag to its captured -// value, never a default. +// value, never a default. Callers must have checked holdsSnapshotFlags. TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap); // Auto-tag decision: new content takes the active mode at creation; the diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index 56fc076..3e1e13e 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -29,9 +29,25 @@ 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. +- **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, @@ -39,7 +55,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 7bf8ab9..5c947fd 100644 --- a/src/shell/actions/design_view_actions.cpp +++ b/src/shell/actions/design_view_actions.cpp @@ -25,7 +25,10 @@ #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/view.h" // applyMode + mintManagedLanes (D2 shell) +#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 #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountSelectedTracks @@ -38,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 { @@ -103,7 +104,11 @@ std::vector selectedTrackGuids() { } // Reapplies the CURRENT active mode so a membership mutation takes visible effect -// immediately (park/unpark/re-derive parents). +// 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). void reapplyActiveMode() { applyMode(g_session->view(), g_session->view().activeModeId(), nullptr); } @@ -139,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 @@ -184,77 +201,137 @@ 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 } +// 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 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). void doTag(const std::string& modeId) { - for (const std::string& g : selectedTrackGuids()) - g_session->view().membership().tag(g, modeId); - reapplyActiveMode(); - persistViewState(PersistScope::Full); + const std::vector guids = selectedTrackGuids(); + if (guids.empty()) return; // nothing selected — no act, no undo point (cf. doMoveItems) + + 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 // 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(); - persistViewState(PersistScope::Full); + const std::vector guids = selectedTrackGuids(); + if (guids.empty()) return; + + 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 // 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(); - persistViewState(PersistScope::Full); + const std::vector guids = selectedTrackGuids(); + if (guids.empty()) return; + + 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 // 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); if (ops.empty()) return; // nothing selected, or every selected item was exempt/empty - 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); - } - // 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); + 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/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..6f67a05 100644 --- a/src/shell/bank_ops/CLAUDE.md +++ b/src/shell/bank_ops/CLAUDE.md @@ -26,13 +26,24 @@ 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`. 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 -- `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..1631621 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 { @@ -45,18 +44,15 @@ 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) { - 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 + 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 d6e343e..713dcfb 100644 --- a/src/shell/capture/render_in_place.cpp +++ b/src/shell/capture/render_in_place.cpp @@ -21,7 +21,9 @@ #include "shell/capture/track_guid.h" // guidString #include "shell/panel/panel_input.h" // bankPanelTailSetting #include "shell/persist/session.h" -#include "shell/view/view.h" // applyMode / mintManagedLanes +#include "shell/undo/undo_block.h" // the ONE undo-block opener +#include "shell/view/view.h" // applyMode +#include "shell/view/view_lanes.h" // mintManagedLanes #define REAPERAPI_MINIMAL #define REAPERAPI_WANT_CountTrackMediaItems @@ -41,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 { @@ -132,82 +132,88 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) { // visible — never to choose a tag. Both tags below are absolute. const std::string activeMode = session.view().activeModeId(); - Undo_BeginBlock2(nullptr); + // Both read after the block closes, so both outlive the scope below. + bool placed = false; + std::string freshName; - // 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; - } + // Scoped so the persist below lands OUTSIDE the block — see the note at the close. + { + UndoBlock undo(nullptr); - // 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/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 9cdf01c..d2876e5 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -20,7 +20,8 @@ #include "core/view/view_mode_model.h" // autoTagNewContent / NewItem / AutoTag #include "shell/capture/item_read.h" // itemGuid / itemLaneName — shared item-read seam #include "shell/capture/track_guid.h" // guidString — canonical track GUID key -#include "shell/view/view.h" // mintManagedLanes / transportBlocksModeSwitch +#include "shell/view/view.h" // transportBlocksModeSwitch +#include "shell/view/view_lanes.h" // mintManagedLanes // New-content detection: enumerate live tracks + items and read fixed-lane state to // classify an item's lane as managed vs manual. diff --git a/src/shell/persist/ext_state_io.cpp b/src/shell/persist/ext_state_io.cpp index 02bdaf3..37eb5c9 100644 --- a/src/shell/persist/ext_state_io.cpp +++ b/src/shell/persist/ext_state_io.cpp @@ -211,6 +211,16 @@ bool ReaSamplerSession::saveViewStateOnly() { return true; } +// Its twin bar the dirty mark — the one line that differs is the whole point of the +// split, so the two stay spelled out rather than folded behind a flag. `project` is +// trusted as given rather than resolved via activeSavedProject() — see session.h. +bool ReaSamplerSession::flushViewState(ReaProject* project) { + if (!project) return false; + + writeViewState(project, view_); + return true; +} + bool ReaSamplerSession::writeAssignmentRequest(const std::string& wire) { ReaProject* proj = activeSavedProject(); if (!proj) return false; diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h index e8c4706..f2a05f8 100644 --- a/src/shell/persist/session.h +++ b/src/shell/persist/session.h @@ -31,6 +31,9 @@ #include "core/version/app_version.h" #include "core/view/view_mode_model.h" +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class ReaProject; + namespace reasampler { // Owns the session's BankBook (pool + named banks) and drives persistence @@ -144,6 +147,16 @@ public: // Returns on the SAME rule as saveToActiveProject above, prohibition included. bool saveViewStateOnly(); + // The same write with the dirty mark OMITTED, for a caller already inside REAPER's + // own project save: marking dirty there would leave the project modified the instant + // it was saved, which is the whole reason that caller exists (fx_reclaim's + // ReleaseFlush). `project` is the SAVING project the caller resolved (e.g. via + // GetCurrentProjectInLoadSave()) — this call trusts it rather than re-deriving the + // active tab, which is exactly the mismatch fx_reclaim's caller exists to avoid. Not + // a general-purpose sibling — a mutation site that skips the dirty mark silently + // loses its write when the user closes without saving. + bool flushViewState(ReaProject* project); + // Report-only prune dry-run: feeds the pure core with (present, referenced, // owned) — `present` from the folder enumeration, the other two from the // tracking authority. FAIL-SAFE: tracking state the authority cannot read diff --git a/src/shell/undo/CLAUDE.md b/src/shell/undo/CLAUDE.md new file mode 100644 index 0000000..9bd952d --- /dev/null +++ b/src/shell/undo/CLAUDE.md @@ -0,0 +1,80 @@ +# src/shell/undo — the one REAPER undo-block opener + +## Scope + +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 that neither a block +nor REAPER's own undo ever runs nested over it. + +## Invariants + +- **`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 + 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`. +- **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. +- **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_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 + 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 + +- **`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 + 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..9f10d76 --- /dev/null +++ b/src/shell/undo/undo_block.cpp @@ -0,0 +1,73 @@ +// 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 +#define REAPERAPI_WANT_ValidatePtr2 +#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. + // + // 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. + // + // 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; +} + +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..ef99eb5 --- /dev/null +++ b/src/shell/undo/undo_block.h @@ -0,0 +1,47 @@ +#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. +// +// 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 + +// 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 — 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(); + + // 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/undo/undo_intercept.cpp b/src/shell/undo/undo_intercept.cpp new file mode 100644 index 0000000..afea981 --- /dev/null +++ b/src/shell/undo/undo_intercept.cpp @@ -0,0 +1,17 @@ +// 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/view/fx_reclaim.h" // fxReclaimEndUndoSpan — the ONE idempotent close + +namespace reasampler { + +void endReclaimSpanForUndoCommand(int command, int sectionId) { + if (!isMainUndoRedoCommand(command, sectionId)) return; + // 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(); +} + +} // 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 443c547..ba2b982 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -27,28 +27,22 @@ decide membership or mode rules. lost, only parked with the mode it belongs to. A REAPPLY (target == active — tag/untag/show-both, project load) touches solo not at all. - **Parking a track** (inactive-mode leaf) drives `B_SHOWINTCP=0`, `B_SHOWINMIXER=0` - (hide both panels), `B_MAINSEND=0` (out of mix), `I_FXEN=0` (FX bypassed), and - `TrackFX_SetOffline(track, fx, true)` for each FX (reclaim CPU) — full CPU-park, - not mix-removal-only. -- **A snapshot is only ever taken from a chain no park has touched — BOTH halves - of it.** The trust test reads the four driven flags AND the FX chain, and either - reading parked alone is enough to refuse: each half is the only source of truth - for its own half of the snapshot, so one of them lying poisons the whole record. - The FX half asks "is ANY FX offline", not "every" — no read distinguishes a - park's leftover from one the user set by hand, and only "any" errs toward - committing nothing false. An absent snapshot is NOT evidence of a clean chain — - a saved project, a hand-edited chain and an undo/redo can each present one; the - Gotchas below enumerate the routes that still reach it. A park meeting that pair - REFUSES: no flag write, no FX write, no snapshot, and a - console report NAMING the refused tracks (they are hidden from both the TCP and - the mixer, so a count points at nothing the user can go and find) plus the hand - recovery, printed once per changed refused set per project rather than on every - reapply. Leaving the track as found is the only non-destructive answer once the - pre-park truth is gone. **The refusal's cost is deliberate and it is not - symmetric with "hidden forever":** because it skips the FX half too, a track - whose flags read parked but whose FX are still online keeps them online for - good. Refusing guarantees nothing false is written; it does not promise to - finish the park. Decision at `view_fx_park`'s `decidePark`. + (hide both panels), `B_MAINSEND=0` (out of mix), and `I_FXEN=0` (FX bypassed) — + flags only. Per-FX `TrackFX_SetOffline` is the reclaimer's job (`fx_reclaim`), + off this path entirely. +- **EXPOSE, not refuse.** When no record exists for a track + (`model.snapshot(guid) == nullptr`) and the live chain already reads + park-shaped (`chainReadsParked`, flags only — the FX half is excluded, see its + own comment), parking now would adopt a park with no account behind it as the + user's intent — an undo/redo or reload that dropped the snapshot, a hand/script + edit, or simply a track the user keeps hidden. Instead the tool drives the + track visible/in-mix/FX-enabled, stores no snapshot, and reports it once per + changed set per project (`reportExposedTracks`/`describeExposedTracks`, which + NAME the tracks — they were hidden from both the TCP and the mixer, so a bare + count points at nothing the user can go and find). The next apply sees an + honest chain and parks/captures it normally — self-healing, no user action + required. Daniel's ruling: expose a broken track rather than hide it forever. + Decision at `view_park`'s `parkTrack`. - **Non-destructive restore.** For every flag the tool drives, snapshot the prior value BEFORE parking; on toggle-back restore FROM the snapshot, never to a hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag @@ -66,51 +60,241 @@ decide membership or mode rules. active mode — stateful plugins (convolution, loaded samplers, tail-holding effects) re-initialize on return (load hitch, un-persisted internal state lost). Accepted cost of the CPU reclaim; surfaced at the toggle affordance (tooltip). - **The hitch sits on the switch's synchronous path, deliberately.** Per-FX - offline/online runs inside `applyMode`'s own undo block, between the flag - writes and `Undo_EndBlock2`, so an action-driven switch does not return until - every plugin has moved. That cost is the price of the invariant below, and it - was accepted as such. -- **ONE mode switch is ONE undo point.** Everything the switch wrote into the - project — the driven flags, every per-FX offline/online, the lane state — - rolls back in a single Ctrl-Z. (The `view_state` ext-state write is NOT among - them; it lands after the block, see Gotchas.) What was MEASURED (before any - fix): a `TrackFX_SetOffline` made outside any undo block mints ONE implicit - undo point per call, and a small project showed 10 points for one switch — 1 - for the switch itself and 9 for its FX. That measurement is the whole reason - the FX writes must sit inside a block. Which block is the design decision, and - it is settled: the SWITCH'S OWN. The undo surface - (`vendor/reaper-sdk/sdk/reaper_plugin_functions.h:7745-7822`) is - begin/end/state-change and nothing else — no append-to-point, no coalescing - flag, no undo-silent write — and a block cannot be held open across a return - to the message loop, so ANY deferral of the writes costs a second undo point. - Two points is a rejected design, not a lesser one: do not reintroduce a - deferred queue, an idle drain, or a second block. That an explicit block - suppresses the per-call implicit points is still the PREMISE the whole - arrangement rests on — INFERENCE from the measurement above, not observed; - `docs/VERIFICATION.md` §"Mode switching" is where it gets checked. -- **A reapply that wrote nothing leaves no undo point.** 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. -- **Stated DEVIATION — the undo mask does not keep FX out of a real switch.** The - apply mask (`kApplyUndoMask`) drops `UNDO_STATE_FX` and ORs it back in when - `I_FXEN` or a per-FX offline state actually moved — which every park does. So - any switch that parks at least one track still makes REAPER marshal the - project-wide FX chunk into its undo record. The saving is real only on a - reapply that wrote nothing and on a no-op switch. Narrowing it further would - mean not carrying the FX domain in the undo record at all, which would break - "one switch is one Ctrl-Z." -- **A rapid A→B→A flip pays two full park/restore cycles.** The retired intent - queue let an intent annihilate its own pending inverse, so a fast double-toggle - moved no plugins at all. Synchronous writes have no such window: the first - switch offlines the chain, the second brings it back, and both hitches are - paid. Accepted — the flip was never free (the flag writes always ran twice), - and the alternative costs the one-point invariant above. + The hitch no longer sits on the switch's synchronous path: per-FX + 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. + **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 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 + 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. **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.) + **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; 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 + (it lasts as long as the playback pass), so it ends the span and the parking that + follows mints its own point (`spanEndsAfter`). + 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 + 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. **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. + **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 + 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` + (`main.cpp`) — the only point at which the block still closes over the state it was + 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. 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. +- **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 + 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 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 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 + never matures, so the reclaimer finds every chain already where the record + 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 @@ -148,20 +332,19 @@ 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`.** -- `view_fx_park` — the park surface: ONE track's whole move, both halves together. `parkTrack` is the trust decision (`decidePark` over "does the model hold a snapshot" × "does the chain already read parked", with `decideParkForTrack` owning the live reads BOTH halves of that second term needs), the pre-park snapshot when it is owed, then flags before FX; `restoreTrack` mirrors it, FX before flags, and consumes the snapshot. Each reports back what the caller's block needs (`TrackApplyResult`: did anything move, does the mask owe `UNDO_STATE_FX`, was the park refused, what could a restore not place). Also the `TrackFX_GetFXGUID` identity read all three of snapshot/park/restore address FX through, `trackFlagParm` — the ONE `Flag` → REAPER-parameter mapping they likewise share — the `writeIfChanged` read-before-write every driven parameter goes through, `trackStillLive` (a per-track `ValidatePtr2`, because a plugin unloading is assumed to pump the message loop), and the two console reports (`reportRefusedParks`, which names the tracks and holds the last-reported set so a reapply does not reprint it; `reportFxRestoreDrops`). **The snapshot is consumed where the restore RUNS** — from that line the flags are back at their captured values, and a model still describing the track as parked would let a later persist or reapply replan a restore over whatever the user changed since. +- `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. +- `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`, 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 - The pure mode model (`ViewModeModel`, membership, `reconcile(liveGuids)`, the snapshot-based park/restore planner) lives in `core/view` — reference it, do not duplicate its spec here. -- **`TrackPlan::fxOffline` is read on the RESTORE side only.** `planToggle` builds - every park plan with `fxCount=0`, and this shell's park loop passes `tp.flags` - alone to `parkTrack`, which expands the real writes itself from - `TrackFX_GetCount` — so the park-side field is populated only by tests. A shell - change that starts reading it on the park path is reading an empty vector. - The Two-canvas sub-phase (Phase D2/E)'s settled DAW-application rules (fixed-lane mechanics, mode-aware capture placement, hidden-AND-silenced) are reflected in Invariants above; the membership/lane-ownership model concepts @@ -172,45 +355,49 @@ applies the resulting lane state to live tracks. under whatever mode id is active at that point, not the one the user undid back to. Pre-existing: `snapshots_` already carries this same model-vs-undo split; the solo cache inherits it rather than introducing it. Not fixed here. -- **Which routes reach the no-snapshot-plus-parked-chain pair, re-derived for the - one-point design.** Three of the routes that used to reach it were artifacts of - the retired deferral window, and are now NARROWED TO THE RE-ENTRANCY WINDOW - rather than gone: the flags-written-FX-not moment still exists inside - `parkTrack`, but nothing can observe it except code re-entered through the - pump premise below, and `applyMode` fails closed when re-entered - (`modeApplyInProgress`). The window that remains is what a call REACHABLE FROM - a pumped message loop but not routed through `applyMode` could see — no save, - no reload and no discard reaches it (`main.cpp`'s `OnTimer` defers - `session.poll()` itself, the one caller of `loadFromProject` a re-entered timer - could reach, under this same latch), and there is no queued intent for an undo - to drop. What survives: - - A `view_state` that PARSED but carries no snapshot for the track — a snapshot - `reconcile` pruned while its track was out of the live enumeration, then - undo/redo-restored with the track but not the snapshot. (Asserted by the - branch that introduced the refusal; not re-observed since.) - - A hand or script edit that leaves a chain park-shaped — including the single - likeliest real case, a track with untouched flags and just ONE hand-offlined - plugin (the FX half of the disjunction refuses alone; the flags never have to - read parked too). - - A track the USER keeps hidden from both panels, out of the mix and - FX-bypassed. It reads identically to both of the above and is refused too — - no lost state there at all, which is why the report asserts no cause. - - A REDO of a mode switch, hypothetically — the mechanism and its two - unverified links are in the `view_state` gotcha below, which owns that - question; do not re-derive it here. - - An UNDO of a mode switch (Ctrl-Z) is NOT a route, and no longer needs an - ordering premise to say so: flags and FX moved inside ONE block, so they roll - back together and no intermediate state exists for a park to misread. The redo - direction is not symmetric with it — see below. -- **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 +- **Which routes reach the no-record-plus-parked-chain pair EXPOSE handles.** + A `view_state` that PARSED but carries no snapshot for the track — a snapshot + `reconcile` pruned while its track was out of the live enumeration, then + undo/redo-restored with the track but not the snapshot; a hand or script edit + that leaves a chain park-shaped, including the likeliest real case, a track + with untouched flags and just ONE hand-offlined plugin (`chainReadsParked` + never reads the FX half at all, so this route is flags-only); or a track the + USER keeps hidden from both panels, out of the mix and FX-bypassed, which + 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 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 — 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". +- **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 + 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. 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 model-vs-undo split the solo-cache gotcha describes: **a redo can plausibly - produce a NEW refusal route.** As a HYPOTHESIS — the redo restores the parked + produce a NEW EXPOSE route.** As a HYPOTHESIS — the redo restores the parked flags AND the ext state as the point closed over it, i.e. the PRE-switch `view_state`, carrying no snapshot for those tracks (`UNDO_STATE_MISCCFG` covers extension state, `reaper_plugin.h:1544`). The `projectconfig` reload @@ -218,53 +405,60 @@ applies the resulting lane state to live tracks. plans NEITHER a park (the track is a member of the restored active mode) nor a restore (no snapshot to restore from) — verified in `planToggle`, which pushes nothing at all for an active leaf without a snapshot. The tracks stay parked - with no snapshot, so the next real switch parks them and `decidePark` refuses. + with no snapshot, so the next real switch parks them and `parkTrack` EXPOSEs + them instead. TWO links are unverified and the route dies if either fails: (1) that the point's stored state is captured at `Undo_EndBlock2` and therefore excludes the `persistViewState` write that follows it — the SDK documents no capture-time 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. -- A DIFFERENT strand entirely, which the refusal report does NOT cover: an + 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 Arrange, empty membership), so nothing is planned to park the track, - `decidePark` never runs, and a track saved parked stays stuck with no console + `parkTrack` never runs, and a track saved parked stays stuck with no console line whatsoever. -- **`applyMode`'s block can be nested, and now carries FX writes while it is.** - `render_in_place` calls `applyMode` inside its own `Undo_BeginBlock2` pair, so - that verb's single `UNDO_STATE_ALL` point now absorbs the reapply's FX moves - instead of a separate point landing after it — the better outcome, but it rests - on REAPER coalescing nested blocks, which the SDK header does not document. - The inverse is also open: a plugin unloading is ASSUMED to pump the message - loop, so an action fired mid-apply could open its own block inside this one. - `applyMode` itself cannot be that action — it fails closed when re-entered - (`modeApplyInProgress`, `view.cpp`'s `ApplyLatch`) — but nothing else is - latched, `mintManagedLanes` included, and it opens a block of its own. Both are - `[verify — DAW]`. - That same premise put a residual on track handles, now CLOSED by construction - rather than by a partial guard. The park and restore loops re-`ValidatePtr2` - per track (`trackStillLive`) because they are the ones interleaved with the FX - writes; the whole enumeration is then re-validated ONCE where those loops end, - so every pass after them — lane ops, parent visibility, the solo replay — sees - only handles REAPER still recognizes. A dead track's handle is nulled in place - rather than erased from the vector, which is what `view_solo`'s writers already - skip on. - The latch's own cost, stated: a mode switch or a membership reapply fired while - another apply is on the stack is DROPPED, not queued. The one caller with - nothing to fall back on is `main.cpp`'s load glue — it spends a one-shot signal - — so it tests `modeApplyInProgress` before consuming and retries next tick. The - action callers just no-op, silently (`reportModeSwitchRefused` speaks only for - the transport gate); the user re-fires. `render_in_place`'s own `applyMode` call - is the exception to that recovery path, not to the drop itself: its 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. +- **`applyMode` fails closed when re-entered — a model-consistency guard now, + not a pump-during-FX-writes one.** Nothing `applyMode` calls is known to pump + the message loop any more (the FX writes are gone from it), but a re-entrant + apply would still plan against the same model the outer call is iterating and + consume records out from under it — `modeApplyInProgress` + (`view.cpp`'s `ApplyLatch`) drops the re-entrant call rather than risk that. A + mode switch or membership reapply fired while another apply is on the stack is + DROPPED, not queued; the action callers just no-op, silently + (`reportModeSwitchRefused` speaks only for the transport gate) and the user + re-fires. The one caller with nothing to fall back on is `main.cpp`'s load + glue, which tests `modeApplyInProgress` AND `fxReclaimDrainInProgress` before + spending its one-shot signal and retries on the next tick — the reclaimer's + own drain (`fx_reclaim`'s `DrainLatch`) is latched for the same reason over + the same window: `TrackFX_SetOffline` is what pumps now, and it holds the view + model the drain reads, not `applyMode`. `render_in_place`'s own `applyMode` + call is the exception to the drop's recovery path, not to the drop itself: its + 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. +- **"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 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_fx_park.cpp`) and SWS issue #802 is a + `[verify — DAW]` (see `fxGuidString` in `view_park.cpp`) and SWS issue #802 is a known reason it might not hold: `SNM_MoveOrRemoveTrackFX` reportedly leaves the FXID lines behind on reorder rather than moving them with the plugin. If confirmed, an SWS-driven reorder of a parked track's chain — not a native diff --git a/src/shell/view/fx_reclaim.cpp b/src/shell/view/fx_reclaim.cpp new file mode 100644 index 0000000..fc6979c --- /dev/null +++ b/src/shell/view/fx_reclaim.cpp @@ -0,0 +1,334 @@ +// See fx_reclaim.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). +// The drain's decisions are pure (header, test_fx_reclaim.cpp); this file owns the +// queue's lifetime, the per-step track resolve, and the writes. + +#include "shell/view/fx_reclaim.h" + +#include +#include +#include +#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" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetCurrentProjectInLoadSave +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_TrackFX_GetCount +#define REAPERAPI_WANT_TrackFX_GetOffline +#define REAPERAPI_WANT_TrackFX_SetOffline +#define REAPERAPI_WANT_Undo_BeginBlock2 +#define REAPERAPI_WANT_Undo_EndBlock2 +#define REAPERAPI_WANT_ValidatePtr2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// The derived queue, the project it was derived against, and the timer's wakeup — see +// FxReclaimQueue, which owns all three. +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); +} + +// 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. +bool g_draining = false; +struct DrainLatch { + DrainLatch() { g_draining = true; } + ~DrainLatch() { g_draining = false; } + DrainLatch(const DrainLatch&) = delete; + DrainLatch& operator=(const DrainLatch&) = delete; +}; + +// Resolved fresh from the GUID rather than held across a write, for the same pump +// premise: a plugin load can run arbitrary REAPER code, and a handle taken before it +// is not one this code may trust after it. The caller (ReclaimHost::resolveForStep) +// re-validates `proj` itself before calling this, on every arm, so this scan can +// assume a live project. +MediaTrack* resolveFresh(ReaProject* proj, const std::string& guid) { + TrackHandles handles; + readFolderEntries(proj, handles); + for (const auto& kv : handles) + if (kv.first == guid) return kv.second; + return nullptr; // deleted, or moved to another project +} + +std::set liveTrackGuids(ReaProject* proj) { + TrackHandles handles; + readFolderEntries(proj, handles); + std::set guids; + for (const auto& kv : handles) guids.insert(kv.first); + return guids; +} + +// The live half of drainTick's Host contract (see fx_reclaim.h). +struct ReclaimHost { + ReaProject* project = nullptr; + ReaSamplerSession* session = nullptr; + + // One item's liveChain call plus every writeOffline call it drives all name the + // SAME track, back to back, before drainTick can move to the next item — so the + // full O(T) resolveFresh scan is owed once per item, not once per call. Every + // call after the first re-validates the SAME pointer instead: cheap, and still + // catches a track (or its whole project) that a prior write's pump took down, + // which the scan-per-call version caught too, just at O(T) instead of O(1). + std::string cachedGuid_; + MediaTrack* cachedTrack_ = nullptr; + + // Re-checks the project on EVERY call, both arms: the cached-track fast path used + // to hand `project` straight to ValidatePtr2 without re-validating it first, and a + // prior pumping write can have closed it since the tick's own entry check ran. + // (reaper_plugin_functions.h:7898 says `proj` is IGNORED when the pointer passed + // IS itself a project — not that passing one is only valid when it is; passing the + // project here is still right, just not for the reason once claimed.) + MediaTrack* resolveForStep(const std::string& guid) { + if (!ValidatePtr2(nullptr, project, "ReaProject*")) return nullptr; + if (guid == cachedGuid_ && cachedTrack_ && + ValidatePtr2(project, cachedTrack_, "MediaTrack*")) + return cachedTrack_; + cachedTrack_ = resolveFresh(project, guid); + cachedGuid_ = guid; + return cachedTrack_; + } + + 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); + out.clear(); + out.reserve(static_cast(count < 0 ? 0 : count)); + // The plain 0..count-1 enumeration, matching the park snapshot's — never the + // 0x1000000 input-FX or 0x2000000 container forms. + for (int fx = 0; fx < count; ++fx) out.push_back(fxGuidString(tr, fx)); + 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 + if (TrackFX_GetOffline(tr, fxIndex) == offline) return false; // already there + + // 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); + return true; + } + + // Reached only after the drain has unwound and the queue has proved its generation + // did not move, so the session still holds the model the queue was derived against. + // The spend lands in memory here and reaches disk on the project's next save. + void releaseFx(const std::string& guid) { + session->view().releaseSnapshotFx(guid); + g_releaseFlush.arm(project); + } +}; + +// The drop set the last console report named, and the project it named it for. Same +// one-per-changed-set memo as view_park's reportExposedTracks, which owns the reasoning; +// here the repeat it swallows is an aborted tick's re-derivation resolving identically. +// The pointer is COMPARE-ONLY and never dereferenced. +ReaProject* g_reportedDropOwner = nullptr; +std::vector g_reportedDrops; + +void reportDrops(ReaProject* proj, const std::vector& drops) { + if (!shouldReportDrops(proj == g_reportedDropOwner, g_reportedDrops, drops)) return; + g_reportedDropOwner = proj; + g_reportedDrops = drops; + + FxRestoreDrops total; + for (const TrackFxDrops& d : drops) total.add(d.drops); + const std::string msg = describeFxRestoreDrops(total, static_cast(drops.size())); + // "!SHOW:" so a background tick cannot force the console open + // (reaper_plugin_functions.h:6536). + if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str()); +} + +} // namespace + +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; + // 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)); +} + +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 + // through, and clear the very wakeup this call had just raised. + g_queue.invalidate(); +} + +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; + // 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. 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; + + // beginTick owns what a pin/project mismatch means. What is local to here: the model + // the re-derivation below reads is the ACTIVE project's, because reaching this line + // means OnTimer ran session.poll() this tick — the two guards above are the same pair + // 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 + // invalidated, so a REAPER quit mid-drain simply re-derives and finishes on reopen. + if (g_queue.stale()) { + const ViewModeModel& model = session.view(); + g_queue.adopt(makeQueue( + view::desiredFxState(model, model.activeModeId(), liveTrackGuids(project)))); + } + + 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 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. 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(); +} + +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 + // Save-All or a background-project save can diverge from. EnumProjects(-1, ...) used + // to stand in for it here and was wrong on both of those: it could skip the flush for + // 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. 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 + // Save-As. [verify — DAW] whether this write reaches the block of the + // file being saved right now, or lands one save late (see ReleaseFlush) — either + // way the arm is deliberately left standing rather than cleared on this one issue. + session.flushViewState(saving); +} + +} // namespace reasampler diff --git a/src/shell/view/fx_reclaim.h b/src/shell/view/fx_reclaim.h new file mode 100644 index 0000000..753a3fd --- /dev/null +++ b/src/shell/view/fx_reclaim.h @@ -0,0 +1,725 @@ +#pragma once +// The FX reclaimer: the convergence loop that drives every parked chain offline and +// every returning chain back to what its park record captured, off the mode switch's +// synchronous path (TrackFX_SetOffline was 1897ms of a measured 1942ms switch). The +// drain's decisions are pure and live here; the REAPER writes live in the .cpp. + +#include +#include +#include +#include +#include + +#include "core/view/fx_state.h" + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class ReaProject; + +namespace reasampler { + +class ReaSamplerSession; + +// --- tuning ------------------------------------------------------------------ + +// A track's OFFLINE work fires only after it has been continuously parked this long. +// The consequence is wanted, not tolerated: a fast A->B->A flip moves zero plugins, +// because the offline never fires and the online that follows finds every chain +// already where the record wants it. +inline constexpr double kParkHoldMs = 3000.0; + +// Per-tick budgets, set against the measured per-plugin costs (Phase Plant 36ms +// offline / 105ms online; MTurboReverb 42ms / 240ms). A step is one whole track's +// chain and cannot be split, so a budget decides only how many TRACKS a tick moves -- +// and a single track with a long chain overruns it by as much as that chain costs. +// +// ONLINE 120ms: onlining restores state the user owns and is waiting on. The check +// runs only AFTER a step, so against the cited 105ms typical load it admits TWO +// typical tracks per tick (105 < 120, so a second runs; 210 >= 120 stops a third), +// not one -- worst case a tick runs over budget by up to one track's whole cost. +// OFFLINE 30ms: background work must not make the UI feel worse than the switch +// already did. At the cited 36-42ms costs one track alone already clears the +// budget, so opportunistic reclaim moves ONE track and yields -- but that count is a +// property of those costs, not the budget: a cheap chain (a stock JS at ~1ms) stays +// under 30ms for many tracks in a row and all of them move in one tick. Checked only +// AFTER a step either way, never before the first and never mid-step, so a track +// dearer than the whole budget still runs; that is what makes forward progress +// unconditional. +inline constexpr double kOnlineBudgetMs = 120.0; +inline constexpr double kOfflineBudgetMs = 30.0; + +inline double tickBudgetMs(view::FxWorkKind kind) { + return kind == view::FxWorkKind::Online ? kOnlineBudgetMs : kOfflineBudgetMs; +} + +// --- the queue: a cache of intent, never state -------------------------------- + +// One derived work item plus this loop's own completion mark. The work is +// core/view/fx_state's; nothing here is persisted, and the whole queue is discarded +// and re-derived on any model change. +struct ReclaimItem { + view::FxTrackWork work; + bool done = false; +}; + +inline std::vector makeQueue(std::vector work) { + std::vector items; + items.reserve(work.size()); + for (view::FxTrackWork& w : work) items.push_back(ReclaimItem{std::move(w), false}); + return items; +} + +inline bool queueSettled(const std::vector& items) { + for (const ReclaimItem& it : items) + if (!it.done) return false; + return true; +} + +// When each track owed OFFLINE work was first observed parked. Keyed on the track, not +// on the queue, so it OUTLIVES a re-derivation: a membership edit somewhere else must +// not restart an unrelated track's hold. An entry is dropped the moment its track stops +// owing offline work, which is what makes a flip start a fresh hold rather than resume +// the one it interrupted. +class ParkClock { +public: + void observe(const std::vector& items, double nowMs) { + std::map next; + for (const ReclaimItem& it : items) { + if (it.work.kind != view::FxWorkKind::Offline) continue; + auto prior = since_.find(it.work.trackGuid); + next[it.work.trackGuid] = prior != since_.end() ? prior->second : nowMs; + } + since_.swap(next); + } + + // Absent answers false: observe() runs first every tick, so an unknown GUID is one + // that owes no offline work at all. + bool matured(const std::string& guid, double nowMs) const { + auto it = since_.find(guid); + return it != since_.end() && nowMs - it->second >= kParkHoldMs; + } + + void forget() { since_.clear(); } + + // The owner swaps the clock out for the duration of a drain — see FxReclaimQueue. + void swap(ParkClock& other) { since_.swap(other.since_); } + +private: + std::map since_; +}; + +// Online before offline, and an offline item only once its hold has matured. +// desiredFxState already sorts Online first, so a plain scan carries the direction +// priority; this adds only the debounce. +inline int nextEligible(const std::vector& items, const ParkClock& clock, + double nowMs) { + for (std::size_t i = 0; i < items.size(); ++i) { + if (items[i].done) continue; + if (items[i].work.kind == view::FxWorkKind::Offline && + !clock.matured(items[i].work.trackGuid, nowMs)) + continue; + return static_cast(i); + } + 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 +// never produce one: its targets are the live chain's own slots, so every op resolves. +struct TrackFxDrops { + std::string trackGuid; + FxRestoreDrops drops; + + bool operator==(const TrackFxDrops& o) const { + return trackGuid == o.trackGuid && drops == o.drops; + } +}; + +// True when a drop set is worth printing: any change of owner or of the set itself. +// The one-per-changed-set memo this gates is view_park's `shouldReport`/ +// `reportExposedTracks`, which states the reason; the shell holds the memo. +// +// A tick that lost nothing is never worth printing AND must leave the memo alone — it +// is not evidence about the last set that DID lose something, and letting it overwrite +// the memo re-arms the identical report for the next re-derivation to print again. +// Answering false is what holds the memo back, so this is the one gate for both. +inline bool shouldReportDrops(bool sameOwnerAsLast, const std::vector& last, + const std::vector& now) { + if (now.empty()) return false; + return !(sameOwnerAsLast && last == now); +} + +// What one tick did. `settled` means every item is complete and the caller may go idle +// until the next invalidation. An item merely waiting out its debounce is NOT settled — +// going idle there would strand its reclaim for the rest of the session. +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 — + // see FxReclaimQueue::run. + std::vector released; + + // Per track that lost any, what the tick could not place. Carried out for the shell + // to print through core/view/fx_offline's describeFxRestoreDrops — a plugin deleted + // while its track was parked is a degrade the user has to repair by hand, and + // swallowing it leaves no trace at all. + std::vector drops; +}; + +// One tick of the convergence loop. Templated on `Host` so the ordering, budget and +// release bookkeeping are all exercised without REAPER (tests/test_fx_reclaim.cpp); +// fx_reclaim.cpp holds the one live implementation. Host must provide: +// +// double nowMs() monotonic milliseconds +// bool transportRolling() playing or recording — gates the +// OFFLINE direction only; a tick +// 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, 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 +// makes the spend durable; called +// by FxReclaimQueue::run after the +// drain has unwound, never from +// inside the drain +// +// `items` and `clock` must be the caller's OWN storage, not shared state a re-entered +// call could reach: a write pumps the message loop. FxReclaimQueue::run is what makes +// that true for the live loop. +template +ReclaimTickResult drainTick(std::vector& items, ParkClock& clock, Host& host) { + ReclaimTickResult result; + + // Run every tick, rolling or not: matured()'s own contract ("observe() runs first + // every tick") already assumed this, and a track can stop owing offline work on a + // rolling tick too now that this loop keeps writing online through one — a + // continuously-parked track's origin is untouched (observe carries the prior + // timestamp forward), so the only thing this changes is dropping a hold the moment + // its track genuinely stops owing offline work, matured or not. + clock.observe(items, host.nowMs()); + + // 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) { + return nextMovable(items, clock, nowMs, rolling, result.transportDeferred); + }; + + const int first = nextAllowed(host.nowMs()); + if (first < 0) { + result.settled = queueSettled(items); + return result; + } + + const view::FxWorkKind tickKind = items[static_cast(first)].work.kind; + const double budget = tickBudgetMs(tickKind); + const double start = host.nowMs(); + + for (;;) { + const int i = nextAllowed(host.nowMs()); + if (i < 0) break; + ReclaimItem& item = items[static_cast(i)]; + // Offline work never rides the online budget: the two directions are priced + // apart deliberately, and a tick that started on one must not spend the other's. + if (item.work.kind != tickKind) break; + + // A track gone from the project writes nothing and releases nothing — the next + // apply's reconcile drops the whole record for a GUID that left it. + std::vector liveFxGuids; + if (host.liveChain(item.work.trackGuid, liveFxGuids)) { + // Held in a named local: through C++20 a range-for does not extend the + // lifetime of a temporary reached through a member access, so iterating the + // resolution's `.writes` directly off the call would dangle. + const FxRestoreResolution resolved = view::resolveFxWork(item.work, liveFxGuids); + for (const FxOfflineWrite& w : resolved.writes) + if (host.writeOffline(item.work.trackGuid, w.fxIndex, w.offline)) ++result.writes; + if (resolved.drops.total() > 0) + result.drops.push_back(TrackFxDrops{item.work.trackGuid, resolved.drops}); + + // The record is spent once the chain has been driven to every captured state + // that is still PLACEABLE — which is why the release fires with drops + // outstanding: a dropped entry names an FX the chain no longer has, so + // holding the record for it would replan work that can never resolve. + // Releasing any EARLIER is what destroys the only account of which plugins + // the USER had offline before the park, and the next online blanket-enables + // them. Reported rather than applied; see ReclaimTickResult::released. + if (item.work.kind == view::FxWorkKind::Online) + result.released.push_back(item.work.trackGuid); + } + item.done = true; // both arms: a step is atomic, so no item is half-moved + + if (host.nowMs() - start >= budget) break; // between tracks only + } + + result.settled = queueSettled(items); + return result; +} + +// --- the queue's owner ---------------------------------------------------------- + +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. + bool aborted = false; + + // Carried out on the abort path TOO, unlike the releases: a drop is a fact about the + // chain the tick just wrote, not a claim about the model a re-entered load may have + // replaced. The re-derivation that follows an abort resolves the very same set, + // which is what shouldReportDrops is there to swallow. + std::vector drops; +}; + +// Owns the derived queue, the debounce clock, the project both were derived against, and +// the timer's wakeup — together, because it is their interaction that decides whether a +// tick may happen at all. A `ReaProject*` here is COMPARE-ONLY and never dereferenced, so +// the whole state machine is exercisable without REAPER (tests/test_fx_reclaim.cpp). +// +// The queue must also survive being re-entered: TrackFX_SetOffline is ASSUMED to pump the +// message loop, so an invalidation can arrive with a drain half-way through a track. The +// two halves of that safety are structural, not guarded. `invalidate()` bumps a counter +// and raises the wakeup and touches NOTHING else, so there is nothing for it to clear +// under a drain. `run()` moves the queue and the clock into locals for the drain's whole +// duration, so the object holds nothing the drain is reading — and re-checks the counter +// before it commits either the surviving queue or a single release. +class FxReclaimQueue { +public: + void invalidate() { + ++generation_; + pending_ = true; + } + + // 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. + // + // 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() { + ++generation_; + abandoned_ = true; + discard(); + pending_ = false; + // 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 + // 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_; } + + // The project the current queue and holds were derived against, or nullptr when idle. + ReaProject* project() const { return project_; } + + // The tick's entry transition, and the ONE place the pin moves. `project` is the + // validated active project, or nullptr when there is none; false means the caller + // must return without touching REAPER. + // + // A project that does not match the pin is a TAB SWITCH — the ordinary path, not a + // corner. Neither the derived work nor the holds are evidence about the project we + // arrived in, so both go, and the tick proceeds to re-derive against the new one. The + // WAKEUP STANDS: clearing it here (an earlier goIdle in this branch did) stranded the + // incoming project's whole reclaim, because the timer gates every tick on it and + // nothing re-raises it short of a mode switch or a membership edit there. Only the + // no-project arm may clear it — nothing to converge, and nothing to come back to. + bool beginTick(ReaProject* project) { + if (!project) { + goIdle(); + clock_.forget(); + return false; + } + if (project_ && project_ != project) { + discard(); + clock_.forget(); + } + project_ = project; + return true; + } + + // Settled: drop the queue and the pin and stop the timer's ticks. The debounce clock + // deliberately survives — it is keyed on the track, not on the queue. + void goIdle() { + discard(); + project_ = nullptr; + pending_ = false; + } + + // No queue at all, or one derived before the last invalidation. + bool stale() const { return !derived_ || derivedAt_ != generation_; } + + void adopt(std::vector items) { + items_ = std::move(items); + derived_ = true; + derivedAt_ = generation_; + } + + void discard() { + items_.clear(); + derived_ = false; + } + + 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. + 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 + // 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 + // the same Online item, its writes all find the chain already there, and the + // 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); + run.settled = tick.settled; + return run; + } + +private: + std::vector items_; + ParkClock clock_; + ReaProject* project_ = nullptr; + unsigned long long generation_ = 0; + unsigned long long derivedAt_ = 0; + bool derived_ = false; + bool pending_ = false; + bool abandoned_ = false; + FxReclaimActivity draining_ = FxReclaimActivity::Idle; +}; + +// --- the release's durable half -------------------------------------------------- + +// A park record spent in MEMORY only, waiting for the project's next save. +// +// The drain used to persist it itself, and that dirtied the project: open one saved +// mid-reclaim, let the drain finish, and the user is asked to save a project they only +// opened. The tool no longer marks a project dirty on its own initiative — it writes what +// it owes when the USER saves. The exposure that buys is the window between the two: die +// in there and the record survives on disk unspent, so the re-derived Online item drives +// the chain to it again, reverting any per-FX offline set since the reclaim finished. +// +// Keyed on the project so a release for one project's model can never land in a +// different project's ext state (checked against GetCurrentProjectInLoadSave() at the +// hook — see fxReclaimFlushRelease). A stale arm simply never fires — the same +// recovery leg. +// +// `clear()` is deliberately NOT called after a single issued write. [verify — DAW] +// whether a SetProjExtState made from inside SaveExtensionConfig reaches the +// block of the very file being written now, or lands one save later — +// project_config_extension_t (reaper_plugin.h:1196-1208) documents no ordering, and +// SetProjExtState (reaper_plugin_functions.h:6288) promises only "restored the next +// time this project is loaded". If the unfavourable answer holds, clearing here would +// strand the spend in memory for the rest of the session with nothing to retry it — +// so the arm stays armed and a further save simply re-issues the same idempotent +// write. +class ReleaseFlush { +public: + void arm(ReaProject* project) { project_ = project; } + bool pendingFor(ReaProject* active) const { return project_ && project_ == active; } + void clear() { project_ = nullptr; } + +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 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); + 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 +// 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 +// — every membership, snapshot and active-mode mutation reapplies through it — on the +// refusal path as well as the success one (see view.cpp for why the refusal is the +// load-bearing half). The debounce clock deliberately survives: it is keyed on the +// track, not on the queue. +void invalidateFxReclaim(); + +// True while a drain is on the stack. Exposed so the timer's other work can stand off +// the model the drain is holding, the same way it stands off an applyMode. +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, 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); + +// Opens the undo span a mode switch and its background FX parking share, so the two are +// 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 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 +// 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 +// settling, aborting, or deferring on the transport; the project retargeting or closing; +// 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. +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 +// 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 +// 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 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 +// 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 +// it deliberately does not mark the project dirty (see ReleaseFlush). +void fxReclaimFlushRelease(ReaSamplerSession& session); + +} // namespace reasampler diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index dd5aee2..7a2fe9a 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -1,97 +1,50 @@ // See view.h. Compiled into the reaper_reasampler module; includes // reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). -// Tree arithmetic lives in view_tree (pure) and one track's park/restore in -// view_fx_park; this file owns the plan iteration, lanes, parents and the one -// undo block they all ride. +// 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 undo span a REAL switch rides (fx_reclaim's UndoSpan, +// which it hands to the reclaimer still open). #include "shell/view/view.h" -#include #include -#include #include #include -#include -#include #include -#include "shell/capture/item_read.h" -#include "core/view/fx_offline.h" -#include "core/view/lane_keys.h" #include "core/view/solo_cache.h" -#include "shell/capture/track_guid.h" -#include "shell/view/view_fx_park.h" -#include "shell/view/view_solo.h" #include "core/view/view_tree.h" +#include "shell/view/fx_reclaim.h" +#include "shell/view/view_lanes.h" +#include "shell/view/view_park.h" +#include "shell/view/view_reconcile.h" +#include "shell/view/view_solo.h" +#include "shell/view/view_tracks.h" #define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_CountTracks #define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_GetPlayStateEx -#define REAPERAPI_WANT_GetTrack -#define REAPERAPI_WANT_GetMediaTrackInfo_Value -#define REAPERAPI_WANT_SetMediaTrackInfo_Value -#define REAPERAPI_WANT_GetSetMediaTrackInfo_String -#define REAPERAPI_WANT_PreventUIRefresh -#define REAPERAPI_WANT_Undo_BeginBlock2 -#define REAPERAPI_WANT_Undo_EndBlock2 #define REAPERAPI_WANT_TrackList_AdjustWindows #define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_UpdateTimeline #define REAPERAPI_WANT_ValidatePtr2 -// Lane minting: item-side lane reads/writes assigning each item to its mode's lane. -#define REAPERAPI_WANT_CountTrackMediaItems -#define REAPERAPI_WANT_GetTrackMediaItem -#define REAPERAPI_WANT_GetMediaItemInfo_Value -#define REAPERAPI_WANT_SetMediaItemInfo_Value #include "reaper_plugin_functions.h" namespace reasampler { using view::buildFolderTree; -using view::isOnManualLane; -using view::managedLaneKey; -using view::modeIdFromLaneName; using view::TrackFolderEntry; namespace { -// I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes. -constexpr int kFreeModeFixedLanes = 2; - // GetPlayStateEx bitmask. SDK: &1 playing, &2 paused, &4 recording — paused is // deliberately excluded, nothing is moving there. constexpr int kTransportMoving = 1 | 4; -// The domains one apply actually writes: track config/routing (visibility, -// B_MAINSEND, I_SOLO), item lane assignment, and the ext-state block -// (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, which -// includes UNDO_STATE_FX and so makes every undo record carry every track's FX -// state — a cost that scales with the project's plugin count rather than with -// what the switch changed. FX is OR'd back in per apply, only when I_FXEN or a -// per-FX offline state really moved (TrackApplyResult::fxMoved). -// [verify — DAW] INFERRED, not documented: UNDO_STATE_TRACKCFG reads -// "track/master vol/pan/routing" (reaper_plugin.h:1541) and names neither -// B_SHOWINTCP/B_SHOWINMIXER nor the fixed-lane properties (assumed to ride -// UNDO_STATE_ITEMS, :1543). Wrong ⇒ a Ctrl-Z restores less than the switch did. -constexpr int kApplyUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG; - -// Holds REAPER's UI refresh off for the write phase; the deliberate rebuild -// (TrackList_AdjustWindows + UpdateArrange) runs after it releases. RAII because -// an unbalanced pair leaves the user's UI frozen with no way back — the SDK's -// own warning at reaper_plugin_functions.h:5581. -struct UiRefreshHold { - UiRefreshHold() { PreventUIRefresh(1); } - ~UiRefreshHold() { PreventUIRefresh(-1); } - UiRefreshHold(const UiRefreshHold&) = delete; - UiRefreshHold& operator=(const UiRefreshHold&) = delete; -}; - -// applyMode is reachable from the load tick, the actions and render_in_place, and its -// TrackFX_SetOffline calls are assumed to pump the message loop — so any of them can -// re-enter it mid-apply, planning against the same model the outer loop is iterating. -// An inner restoreTrack consuming a snapshot the outer sits mid-park over leaves that -// track flags-restored, chain-offline and snapshotless: refused forever. Fail closed. +// applyMode is reachable from the load tick, the actions and render_in_place. Nothing +// it calls is known to pump the message loop now that the FX writes are gone, but a +// re-entrant apply would plan against the same model the outer loop is iterating and +// consume records out from under it — one dropped call is cheaper than that. Fail closed. bool g_applying = false; struct ApplyLatch { ApplyLatch() { g_applying = true; } @@ -100,275 +53,6 @@ struct ApplyLatch { ApplyLatch& operator=(const ApplyLatch&) = delete; }; -// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane -// track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist -// but displays as non-fixed-lane). -constexpr int kLanesDisplayAsNormal = 2; - -// C_LANESETTINGS &32 = hide per-lane buttons; OR'd in, never clobbering the -// mask. Deliberately NOT setting &1 (auto-remove empty lanes): the lazy-mint -// decision never mints an empty lane, so &1 buys nothing and risks REAPER -// silently removing a managed lane out from under the ownership index. -constexpr int kLaneSettingsHideButtons = 32; - -// Makes a tool-split track's display read as an ordinary track. Gated by every -// caller on the tool-driven transition INTO fixed lanes (freeMode != 2 before -// the flip) — a track already in fixed-lane mode (the user's own) never -// reaches this, so a user's comp-lane display prefs are never stomped. -void applyTransparentLaneDisplay(MediaTrack* tr) { - SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED", - static_cast(kLanesDisplayAsNormal)); - const int settings = static_cast(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS")); - SetMediaTrackInfo_Value(tr, "C_LANESETTINGS", - static_cast(settings | kLaneSettingsHideButtons)); -} - -// The master track is not enumerated by GetTrack (index space excludes it), -// so it can never enter the tree — the master-untouched invariant holds by -// construction. -std::vector readFolderEntries( - ReaProject* proj, - TrackHandles& handleByGuid) { - std::vector entries; - int count = CountTracks(proj); - entries.reserve(static_cast(count)); - handleByGuid.reserve(static_cast(count)); - for (int i = 0; i < count; ++i) { - MediaTrack* tr = GetTrack(proj, i); - if (!tr) continue; - std::string guid = guidString(tr); - if (guid.empty()) continue; - int depth = static_cast(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH")); - entries.push_back(TrackFolderEntry{guid, depth}); - handleByGuid.emplace_back(guid, tr); - } - return entries; -} - -// Every park, restore, parent and lane op resolves its GUID; a linear scan made -// that O(T²) on the one path whose cost the user waits on. The vector form -// survives beside it because view_solo's writers take one pass over it. -using TrackByGuid = std::unordered_map; - -TrackByGuid indexHandles(const TrackHandles& handleByGuid) { - TrackByGuid byGuid; - byGuid.reserve(handleByGuid.size()); - for (const auto& kv : handleByGuid) byGuid.emplace(kv.first, kv.second); - return byGuid; -} - -MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) { - auto it = byGuid.find(guid); - return it == byGuid.end() ? nullptr : it->second; // stale/deleted GUID — pruned by being skipped -} - -// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed / -// unavailable (non-fixed-lane track). -std::string laneName(MediaTrack* tr, int laneIdx) { - char parm[32]; - std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); - char buf[512] = {0}; - if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {}; - return std::string(buf); -} - -// Managed lane durable key -> current ordinal on `tr`. Manual lanes are -// omitted, so a key absent from the map must not be driven. -std::map managedLaneOrdinals(MediaTrack* tr) { - std::map byKey; - const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); - for (int lane = 0; lane < numLanes; ++lane) { - std::optional key = managedLaneKey(laneName(tr, lane)); - if (key) byKey.emplace(*key, lane); // first ordinal wins if names collide - } - return byKey; -} - -// Track-side C_LANEPLAYS:N alone hides+silences every item on lane N (SDK: -// item-side C_LANEPLAYS is read-only, so no per-item write exists or is -// needed). B_FIXEDLANE_HIDDEN is also read-only — hide/show follows from -// C_LANEPLAYS=0/1, never written directly. -bool applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) { - char parm[32]; - std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx); - return writeIfChanged(tr, parm, static_cast(lanePlays)); -} - -// `freeMode` alone decides whether UpdateTimeline is owed; `wrote` is any project -// write at all, which is what the apply's undo-point decision reads. -struct LaneApplyResult { - bool freeMode = false; - bool wrote = false; -}; - -// Groups ops by track, reconciles each op's durable laneKey to the track's -// current ordinal (a stale/renamed/deleted key is pruned, never mis-driven), -// enables fixed-lane mode on any track carrying a managed lane, and drives -// C_LANEPLAYS. UpdateTimeline() is the caller's job when `freeMode` comes back -// true (SDK: required after an I_FREEMODE change). -LaneApplyResult applyLaneOps(const TrackByGuid& handleByGuid, - const std::vector& lanes) { - LaneApplyResult out; - if (lanes.empty()) return out; - - std::map> byTrack; - for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op); - - for (const auto& [guid, ops] : byTrack) { - MediaTrack* tr = resolve(handleByGuid, guid); - if (!tr) continue; // stale GUID — prune - - // Every track reaching here already owns a managed lane (planToggle - // only emits ops for managed lanes), so re-asserting fixed-lane mode - // is always a tool-driven (re)split — never a user's untouched - // manual-fixed-lane track — and gets the same transparent display. - const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); - if (freeMode != kFreeModeFixedLanes) { - SetMediaTrackInfo_Value(tr, "I_FREEMODE", - static_cast(kFreeModeFixedLanes)); - applyTransparentLaneDisplay(tr); - out.freeMode = true; - out.wrote = true; - } - - const std::map ordinals = managedLaneOrdinals(tr); - for (const LanePlayOp* op : ops) { - auto it = ordinals.find(op->laneKey); - if (it == ordinals.end()) continue; // key not live on this track — prune - if (applyLanePlays(tr, it->second, op->lanePlays)) out.wrote = true; - } - } - return out; -} - -// One pass, so the assign loop needs no per-item re-scan. -std::map itemHandlesByGuid(MediaTrack* tr) { - std::map byGuid; - const int itemCount = CountTrackMediaItems(tr); - for (int i = 0; i < itemCount; ++i) { - MediaItem* it = GetTrackMediaItem(tr, i); - if (!it) continue; - std::string ig = itemGuid(it); - if (!ig.empty()) byGuid.emplace(std::move(ig), it); - } - return byGuid; -} - -// An untagged item is Arrange by default (mirrors leafBelongsToMode). A -// show-both/multi-mode item resolves to its first mode id — unusual for lane -// content, and any one mode is sufficient for the decision. -std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) { - const std::set modes = model.membership().modesOf(itemGuid); - if (modes.empty()) return kArrangeModeId; - return *modes.begin(); -} - -// The per-track LaneItem picture the pure decision consumes. Manual-lane reads -// are skipped on a non-fixed-lane track (isOnManualLane is false there anyway). -std::vector readLaneTracks( - const ViewModeModel& model, - const TrackHandles& handleByGuid) { - std::vector tracks; - tracks.reserve(handleByGuid.size()); - for (const auto& [guid, tr] : handleByGuid) { - if (!tr) continue; // TrackHandles legally carries nulls post-validation (view.cpp) - LaneTrack lt; - lt.trackGuid = guid; - - const bool fixedLane = - static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; - - const int itemCount = CountTrackMediaItems(tr); - lt.items.reserve(static_cast(itemCount)); - for (int i = 0; i < itemCount; ++i) { - MediaItem* it = GetTrackMediaItem(tr, i); - if (!it) continue; - const std::string ig = itemGuid(it); - if (ig.empty()) continue; - LaneItem li; - li.guid = ig; - li.modeId = itemModeFromMembership(model, ig); - const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; - li.onManualLane = isOnManualLane(fixedLane, ln); - lt.items.push_back(std::move(li)); - } - tracks.push_back(std::move(lt)); - } - return tracks; -} - -// Idempotent, and non-destructive: only this reversible flag is written, never -// a move in time or across tracks. -bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) { - const int current = static_cast(GetMediaItemInfo_Value(it, "I_FIXEDLANE")); - if (current == laneOrdinal) return false; // already there — no-op - SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast(laneOrdinal)); - return true; -} - -// Applies the pure LaneMintPlan. Returns true if any project write actually -// changed state (⇒ caller keeps the Undo block and refreshes the timeline). -// -// The plan only ever names managed-prefixed lanes and only ever assigns -// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk, -// so a user's existing manual lanes are never renamed or reassigned. -bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, - const TrackByGuid& handleByGuid) { - bool changed = false; - - std::map> mintsByTrack; - for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m); - std::map> assignsByTrack; - for (const LaneAssign& a : plan.assigns) assignsByTrack[a.trackGuid].push_back(&a); - - for (const LaneMintPlan::TrackSplit& split : plan.splits) { - MediaTrack* tr = resolve(handleByGuid, split.trackGuid); - if (!tr) continue; // stale GUID — prune - - // A track not already in fixed-lane mode is one the tool is splitting - // now, so it owns the display; a track already at I_FREEMODE==2 (the - // user's own, or a prior tool run) skips this and keeps its display prefs. - const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); - if (freeMode != kFreeModeFixedLanes) { - SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); - applyTransparentLaneDisplay(tr); - changed = true; - } - - // Grow-only: a track may already carry the user's manual lanes, so the - // lane count only ever increases; managed lanes occupy the tail ordinals. - int laneCount = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); - - std::map present = managedLaneOrdinals(tr); - - for (const LaneMint* m : mintsByTrack[split.trackGuid]) { - model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId); // ownership - if (present.count(m->laneKey)) continue; // already minted — idempotent - - const int laneIdx = laneCount++; - SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast(laneCount)); - char parm[32]; - std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); - std::vector name(m->laneKey.begin(), m->laneKey.end()); - name.push_back('\0'); - GetSetMediaTrackInfo_String(tr, parm, name.data(), true); - present.emplace(m->laneKey, laneIdx); // now resolvable for the assign pass - changed = true; - } - - const std::map ordinals = managedLaneOrdinals(tr); - const std::map itemsByGuid = itemHandlesByGuid(tr); - for (const LaneAssign* a : assignsByTrack[split.trackGuid]) { - auto ord = ordinals.find(a->laneKey); - if (ord == ordinals.end()) continue; // key not live — prune, never mis-assign - auto handle = itemsByGuid.find(a->itemGuid); - if (handle == itemsByGuid.end()) continue; // stale item GUID — prune - if (assignItemToLane(tr, handle->second, ord->second)) changed = true; - } - } - return changed; -} - } // namespace bool transportBlocksModeSwitch(ReaProject* proj) { @@ -377,19 +61,22 @@ bool transportBlocksModeSwitch(ReaProject* proj) { bool modeApplyInProgress() { return g_applying; } -bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) { - if (g_applying) return false; // see ApplyLatch; same fail-closed shape as the guards below +namespace { + +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: begin and end sit a whole apply apart with the pump premise between - // them, so resolving "current project" independently at each would let a tab switch - // mid-apply open the block on one project and close it on another. + // 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 @@ -399,18 +86,17 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject 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; std::vector entries = readFolderEntries(project, handleByGuid); - TrackByGuid trackByGuid = indexHandles(handleByGuid); + const TrackByGuid trackByGuid = indexHandles(handleByGuid); FolderTree tree = buildFolderTree(entries); - // Prune snapshots for tracks no longer in the live enumeration before - // planning (membership is intentionally left alone — see model.reconcile). - // Because reapply-on-load routes through applyMode, this also reconciles - // on project open. + // Prune records for tracks no longer in the live enumeration before planning + // (membership is intentionally left alone — see model.reconcile). Because + // reapply-on-load routes through applyMode, this also reconciles on project open. std::set liveGuids; for (const auto& kv : handleByGuid) liveGuids.insert(kv.first); model.reconcile(liveGuids); @@ -429,14 +115,27 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject "ReaSampler: activate " + (targetMode ? targetMode->displayName : targetModeId) + " view"; - int undoMask = kApplyUndoMask; bool laneModeChanged = false; - bool wrote = false; // anything at all this apply changed — see applyMintsUndoPoint - std::vector refusedParkNames; - FxRestoreDrops fxDrops; - int fxDropTracks = 0; + std::vector exposedNames; // named while handles are fresh, reported after the writes - Undo_BeginBlock2(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(); + // 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); + } { UiRefreshHold uiHold; // every write below lands with the TCP/MCP frozen @@ -447,71 +146,50 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject clearTrackSolos(handleByGuid, outgoingSolo); } - // PARK / RESTORE: view_fx_park owns one track's whole move, flags and - // per-FX offline together. A refusal is named HERE, while the handle is - // fresh — never held past Undo_EndBlock2/TrackList_AdjustWindows. + // PARK / RESTORE: view_park owns one track's whole move. Flags only — the + // parked chain stays online until the reclaimer reaches it. for (const TrackPlan& tp : plan.park) { if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(trackByGuid, guid); - // Re-validated per track, not once at resolve: offlining a plugin is - // ASSUMED to pump the message loop, so a track deleted during an - // earlier iteration's FX writes would leave this handle dangling. - if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune - - const TrackApplyResult r = parkTrack(model, guid, tr, tp.flags); - if (r.refused) { refusedParkNames.push_back(trackDisplayName(tr)); continue; } - if (r.wrote) wrote = true; - if (r.fxMoved) undoMask |= UNDO_STATE_FX; + if (!tr) continue; // stale/deleted GUID — prune + const TrackParkResult r = parkTrack(model, guid, tr, tp.flags); + // Named here, not after the block: TrackList_AdjustWindows below can + // invalidate the handle. + if (r.exposed) exposedNames.push_back(trackDisplayName(tr)); } for (const TrackPlan& tp : plan.restore) { if (tp.flags.empty()) continue; const std::string& guid = tp.flags.front().guid; MediaTrack* tr = resolve(trackByGuid, guid); - if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune - - const TrackApplyResult r = restoreTrack(model, guid, tr, tp.fxOffline, tp.flags); - if (r.wrote) wrote = true; - if (r.fxMoved) undoMask |= UNDO_STATE_FX; - if (r.drops.total() > 0) { fxDrops.add(r.drops); ++fxDropTracks; } + if (!tr) continue; // stale/deleted GUID — prune + restoreTrack(model, guid, tr, tp.flags); } - // The FX writes above are the only thing here assumed to pump the message - // loop, so this is the one point a handle taken before them can have died. - // Re-validated for the whole enumeration rather than per consumer: lane ops, - // parent visibility and the solo replay all write through pre-FX handles. - for (auto& kv : handleByGuid) { - if (trackStillLive(project, kv.second)) continue; - trackByGuid.erase(kv.first); - kv.second = nullptr; // view_solo's writers already skip a null handle - } - - // 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. + // 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 - // snapshotted. Only the two visibility flags — never mainSend/FX on a parent. + // PARENT VISIBILITY (never parked): recomputed every toggle, never recorded. + // Only the two visibility flags — never mainSend/FX on a parent. std::set visible = model.visibleTracks(tree, targetModeId); for (const FolderNode& node : tree.nodes) { if (!node.isParent) continue; 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); // Replay + consume, before the single relayout below picks the change up. // Dropped against `visible` (computed above for parent visibility), not the - // park plan: a folder parent can be hidden without being parked, and a - // hidden track must not receive a replayed solo it carries no visible - // control to undo. + // park plan: a folder parent can be hidden without being parked, and a hidden + // track must not receive a replayed solo it carries no visible control to undo. if (realSwitch) { if (const std::map* cached = model.soloCache().query(targetModeId)) { restoreTrackSolos(handleByGuid, *cached, liveGuids, visible); @@ -521,105 +199,50 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject } // Force REAPER to rebuild the TCP/MCP now rather than on the next user - // interaction — AdjustWindows(false) is the full relayout tracks appearing - // and disappearing owes. + // interaction — AdjustWindows(false) is the full relayout tracks appearing and + // disappearing owes. TrackList_AdjustWindows(false); UpdateArrange(); - // UpdateTimeline() is owed only when a track was actually toggled into - // fixed lanes this apply (SDK requirement for I_FREEMODE changes). + // UpdateTimeline() is owed only when a track was actually toggled into fixed lanes + // this apply (SDK requirement for I_FREEMODE changes). if (laneModeChanged) UpdateTimeline(); - const bool mint = applyMintsUndoPoint(realSwitch, wrote); - Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? undoMask : 0); + // 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); - // After the undo block and the UI hold: a report is not a project write, and - // it must not join what a Ctrl-Z rolls back. - reportRefusedParks(project, refusedParkNames); - reportFxRestoreDrops(fxDrops, fxDropTracks); - return true; + // 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. In memory, never on disk. + noteAppliedState(project, model); + + return ModeApplyResult{true}; } -bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { - TrackHandles handleByGuid; - std::vector entries = readFolderEntries(proj, handleByGuid); - // The tree is needed to detect a content-bearing folder derived-visible in - // >1 mode, exactly as applyMode builds it. - const FolderTree tree = buildFolderTree(entries); +} // namespace - const std::vector tracks = readLaneTracks(model, handleByGuid); - const LaneMintPlan plan = planLaneMinting(model, tree, tracks); - if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick +ModeApplyResult applyMode(ViewModeModel& model, const std::string& targetModeId, + ReaProject* proj) { + const ModeApplyResult result = applyModeImpl(model, targetModeId, proj); - const TrackByGuid trackByGuid = indexHandles(handleByGuid); - - // `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) { - // 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 nothing even though the item needs to appear - // there now. UpdateArrange() 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(); - - // Lane minting writes track lane config and item lane assignment and nothing - // else — never an FX state — so it takes the same honest mask applyMode does - // (kApplyUndoMask, whose [verify — DAW] on lane-property domain binds here too). - Undo_EndBlock2(proj, "ReaSampler: separate cross-mode content into lanes", kApplyUndoMask); - return true; -} - -void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { - TrackHandles handleByGuid; - readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here) - - // Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/ - // I_FIXEDLANE written) plus an ownership-index write, recovering managed - // classification from the durable name. An unprefixed lane is left alone. - for (const auto& [guid, tr] : handleByGuid) { - const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); - if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed - - const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); - for (int lane = 0; lane < numLanes; ++lane) { - const std::string name = laneName(tr, lane); - std::optional key = managedLaneKey(name); - if (!key) continue; // manual/unnamed lane — leave off the index - std::optional mode = modeIdFromLaneName(name); - if (!mode) continue; // prefix-only/illegal name — skip defensively - - // A mode id encoded in the name may no longer be registered (e.g. - // removed since the project was saved). Recording it managed would - // make the toggle planner drive a lane keyed to a mode that can - // never be active — permanently silenced, orphaning its items. So - // skip: the lane stays off the index (manual-by-default) but keeps - // its name, so a later re-registration of the mode heals cleanly. - if (!model.modes().contains(*mode)) continue; - model.lanes().setManaged(guid, *key, *mode); - } - } + // Every membership, snapshot and active-mode mutation in the system reaches the + // project through this call, so this is the whole invalidation surface: the + // reclaimer's queue is a cache of what the FX chains SHOULD be, never state. + // + // Dropped on a REFUSAL too, and that is the load-bearing half. Callers mutate the + // model FIRST and reapply to make it visible (design_view_actions' tag/untag/ + // show-both, render_in_place, the load tick), and every one of them discards this + // return — so a refusal is exactly the case where the mutation stands and nothing + // 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 result; } } // namespace reasampler diff --git a/src/shell/view/view.h b/src/shell/view/view.h index 46d3a8c..933d377 100644 --- a/src/shell/view/view.h +++ b/src/shell/view/view.h @@ -1,10 +1,9 @@ #pragma once -// REAPER-facing shell of Design View (D2): reads the live folder tree, runs -// ViewModeModel's pure planner, and applies the resulting flag / per-FX / -// lane writes. The .cpp is the sole REAPER-facing TU here (CLAUDE.md contract: -// only main.cpp defines the API pointers). See src/shell/view/CLAUDE.md for -// the enforced invariants (never touch master/mute, solo cached per mode and -// never lost, snapshot-based restore). +// 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 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 @@ -23,53 +22,53 @@ 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 and every FX 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 uses 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. 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; -} - -// True while an applyMode call is on the stack. applyMode fails closed when re-entered -// (its FX writes are assumed to pump the message loop, so a timer tick or an action can -// land inside one), and this lets a caller with no fallback test BEFORE it spends a +// 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. bool modeApplyInProgress(); -// Snapshots each about-to-park track's flags into `model`, caches/clears the -// outgoing mode's solo state and replays the incoming mode's, runs planToggle, -// applies park/restore writes — flags AND per-FX offline, synchronously — plus -// parent visibility flags, then sets the active mode. ONE switch is ONE undo -// point; everything it moved rolls back in a single Ctrl-Z. 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 +// What one applyMode did. +// +// 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; +}; + +// 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 +// writes, then sets the active mode. ONE switch is ONE undo point; everything it +// 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 +// 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 opens none, so a +// 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. +// +// `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. -bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj); - -// 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`. Never touches manual lanes. Runs planLaneMinting -// (model + tree aware); 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. -bool mintManagedLanes(ViewModeModel& model, ReaProject* proj); - -// Recovers managed-lane ownership from durable P_LANENAME on project open — -// self-healing, without minting/reassigning anything and without touching -// membership. A lane without the managed prefix is left untouched (manual). -// `proj` == nullptr means the current project. -void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj); +// 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. +ModeApplyResult applyMode(ViewModeModel& model, const std::string& targetModeId, + ReaProject* proj); } // namespace reasampler diff --git a/src/shell/view/view_fx_park.cpp b/src/shell/view/view_fx_park.cpp deleted file mode 100644 index db76d54..0000000 --- a/src/shell/view/view_fx_park.cpp +++ /dev/null @@ -1,227 +0,0 @@ -// See view_fx_park.h. Compiled into the reaper_reasampler module; includes -// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). -// The decisions are pure (header, test_view_fx_park.cpp); this file owns the -// REAPER reads/writes and their ordering against the live enumeration. - -#include "shell/view/view_fx_park.h" - -#include // strnlen — bounded read of GetTrackName's buffer -#include -#include - -#define REAPERAPI_MINIMAL -#define REAPERAPI_WANT_EnumProjects -#define REAPERAPI_WANT_GetMediaTrackInfo_Value -#define REAPERAPI_WANT_GetTrackName -#define REAPERAPI_WANT_SetMediaTrackInfo_Value -#define REAPERAPI_WANT_ShowConsoleMsg -#define REAPERAPI_WANT_TrackFX_GetCount -#define REAPERAPI_WANT_TrackFX_GetFXGUID -#define REAPERAPI_WANT_TrackFX_GetOffline -#define REAPERAPI_WANT_TrackFX_SetOffline -#define REAPERAPI_WANT_ValidatePtr2 -#define REAPERAPI_WANT_guidToString -#include "reaper_plugin_functions.h" - -namespace reasampler { - -namespace { - -// The last refusal report, so an unchanged one is not reprinted on every reapply. -ReaProject* g_reportedOwner = nullptr; -std::vector g_reportedRefusals; - -ReaProject* currentProject() { return EnumProjects(-1, nullptr, 0); } - -// Returns whether it actually wrote — the apply's undo point and its -// UNDO_STATE_FX bit both hang off that verdict. -// -// [verify — DAW] TrackFX_SetOffline unloads and re-instantiates the plugin, so -// writing a state that already holds is assumed to cost the same unload/reload -// as a real change — the header documents no internal short-circuit either way. -// The compare is cheap and correct regardless; only the size of what it saves is -// unconfirmed. -bool setOfflineIfChanged(MediaTrack* tr, int fx, bool offline) { - if (TrackFX_GetOffline(tr, fx) == offline) return false; - TrackFX_SetOffline(tr, fx, offline); - return true; -} - -// The FX's own durable identity, braced exactly like the track GUID keys. Empty -// when REAPER reports none — an FX we cannot name is one we cannot restore, and -// fx_offline treats it that way rather than guessing at its slot. Lifetime is -// settled: the string copy is taken immediately and the GUID* is never held -// past this call (reaper_plugin_functions.h:7348 documents no null contract for -// TrackFX_GetFXGUID; treating null as "no identity" is the safe read). -// -// [verify — DAW] STABILITY across a chain mutation is not settled the same way: -// confirm the GUID for one FX instance survives a native drag-reorder, an SWS -// move (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not -// follow the plugin after that call, i.e. wrong-plugin restores or mass drops -// through fx_offline on that path specifically), a save/reload round trip, and -// two live instances of one plugin type staying distinguishable. See -// src/shell/view/CLAUDE.md's Gotchas for the SWS-path risk this leaves open. -std::string fxGuidString(MediaTrack* tr, int fx) { - GUID* g = TrackFX_GetFXGUID(tr, fx); - if (!g) return {}; - char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract) - guidToString(g, buf); - return std::string(buf); -} - -// The chain as it stands now: identity by current slot. Snapshot, park and -// restore all address FX through this one plain 0..TrackFX_GetCount-1 -// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so -// whatever it covers, all three cover identically. -std::vector liveFxGuids(MediaTrack* tr) { - const int fxCount = TrackFX_GetCount(tr); - std::vector guids; - guids.reserve(static_cast(fxCount)); - for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx)); - return guids; -} - -// ANY offline FX, not every — see the snapshot-source invariant (view_fx_park.h, -// src/shell/view/CLAUDE.md) for why. -bool anyFxOffline(MediaTrack* tr) { - const int fxCount = TrackFX_GetCount(tr); - for (int fx = 0; fx < fxCount; ++fx) - if (TrackFX_GetOffline(tr, fx)) return true; - return false; -} - -// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO; -// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract). -// Must run before EITHER half of the park writes — both halves are read here. -// `fxKeying` stays Identity: a live read always knows the chain. -TrackSnapshot snapshotTrack(MediaTrack* tr) { - TrackSnapshot snap; - auto read = [tr](Flag f) { - return static_cast(GetMediaTrackInfo_Value(tr, trackFlagParm(f))); - }; - snap.showInTcp = read(Flag::ShowInTcp); - snap.showInMixer = read(Flag::ShowInMixer); - snap.mainSend = read(Flag::MainSend); - snap.fxEnable = read(Flag::FxEnable); - - const std::vector guids = liveFxGuids(tr); - snap.fxOffline.reserve(guids.size()); - for (std::size_t fx = 0; fx < guids.size(); ++fx) - snap.fxOffline.push_back( - FxOfflineState{guids[fx], TrackFX_GetOffline(tr, static_cast(fx)) ? 1 : 0}); - return snap; -} - -// Returns whether it wrote; raises `fxMoved` when a flag in the FX domain moved. -bool applyFlags(MediaTrack* tr, const std::vector& flags, bool& fxMoved) { - bool wrote = false; - for (const TrackFlagOp& op : flags) { - if (!writeIfChanged(tr, trackFlagParm(op.flag), static_cast(op.value))) continue; - wrote = true; - // I_FXEN is the only FX-domain flag driven here, so it is the only one - // whose undo record has to carry UNDO_STATE_FX. - if (op.flag == Flag::FxEnable) fxMoved = true; - } - return wrote; -} - -} // namespace - -// GetTrackName, not P_NAME, matching capture::trackName's reason for the same -// choice. The buffer is read bounded — the SDK documents no NUL within -// bufOut_sz. A track the report cannot name still has to appear in it, so the -// read failing yields a placeholder rather than dropping the entry. -std::string trackDisplayName(MediaTrack* tr) { - std::vector buf(1024, '\0'); - if (!GetTrackName || !GetTrackName(tr, buf.data(), static_cast(buf.size()))) - return "(name unavailable)"; - return std::string(buf.data(), strnlen(buf.data(), buf.size())); -} - -ParkAction decideParkForTrack(MediaTrack* tr, bool haveSnapshot, - const std::vector& parkFlags) { - if (haveSnapshot) return decidePark(true, false); // second term unread — skip the reads - const bool flagsRead = parkFlagsAlreadyApplied(parkFlags, [tr](Flag f) { - return static_cast(GetMediaTrackInfo_Value(tr, trackFlagParm(f))); - }); - return decidePark(false, chainReadsParked(flagsRead, anyFxOffline(tr))); -} - -void reportRefusedParks(ReaProject* proj, const std::vector& refusedNames) { - ReaProject* const owner = proj ? proj : currentProject(); - - if (!shouldReport(owner == g_reportedOwner, g_reportedRefusals, refusedNames)) return; - g_reportedOwner = owner; - g_reportedRefusals = refusedNames; - - const std::string msg = describeRefusedParks(refusedNames); - // "!SHOW:" so an unattended project-load reapply cannot force the console open - // (reaper_plugin_functions.h:6536). - if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str()); -} - -bool writeIfChanged(MediaTrack* tr, const char* parm, double value) { - if (GetMediaTrackInfo_Value(tr, parm) == value) return false; - SetMediaTrackInfo_Value(tr, parm, value); - return true; -} - -bool trackStillLive(ReaProject* proj, MediaTrack* tr) { - return ValidatePtr2(proj ? proj : currentProject(), tr, "MediaTrack*"); -} - -TrackApplyResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& flags) { - TrackApplyResult out; - - const ParkAction action = decideParkForTrack(tr, model.snapshot(guid) != nullptr, flags); - if (action == ParkAction::Refuse) { - out.refused = true; - return out; - } - if (action == ParkAction::SnapshotThenPark) { - model.storeSnapshot(guid, snapshotTrack(tr)); - out.wrote = true; - } - - if (applyFlags(tr, flags, out.fxMoved)) out.wrote = true; - - const int fxCount = TrackFX_GetCount(tr); - for (int fx = 0; fx < fxCount; ++fx) - if (setOfflineIfChanged(tr, fx, true)) out.fxMoved = out.wrote = true; - return out; -} - -TrackApplyResult restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& fxOps, - const std::vector& flags) { - TrackApplyResult out; - - // Which live FX each captured state belongs to is resolveFxRestore's call — - // the plan is written VERBATIM, never a blanket "online". - const FxRestoreResolution res = resolveFxRestore(fxOps, liveFxGuids(tr)); - for (const FxOfflineWrite& w : res.writes) - if (setOfflineIfChanged(tr, w.fxIndex, w.offline)) out.fxMoved = out.wrote = true; - out.drops = res.drops; - - if (applyFlags(tr, flags, out.fxMoved)) out.wrote = true; - - // Unconditional, even when every write above was elided: the snapshot is consumed - // here, so this apply changed state and is not repeatable — and `wrote`, whose one - // reader asks exactly that, would be a lie at false. NOT a claim about what the - // undo record holds: the block never carries the snapshot either way, that being - // model state persisted after Undo_EndBlock2 (src/shell/view/CLAUDE.md). - model.clearSnapshot(guid); - out.wrote = true; - return out; -} - -void reportFxRestoreDrops(const FxRestoreDrops& drops, int trackCount) { - const std::string msg = describeFxRestoreDrops(drops, trackCount); - // "!SHOW:" (reaper_plugin_functions.h:6536) keeps it from force-opening the - // console: this also runs behind an unattended project-load reapply, and - // cannot tell that case from an interactive toggle. - if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str()); -} - -} // namespace reasampler diff --git a/src/shell/view/view_fx_park.h b/src/shell/view/view_fx_park.h deleted file mode 100644 index a4e7129..0000000 --- a/src/shell/view/view_fx_park.h +++ /dev/null @@ -1,185 +0,0 @@ -#pragma once -// Design View's park surface: one track parked or restored — the trust test, -// the snapshot lifecycle, and the flag + per-FX writes both halves address -// through this one file. Every write declared here runs synchronously inside -// applyMode's own undo block, which is what makes one switch one undo point — -// see src/shell/view/CLAUDE.md's FX-parking caveat. - -#include -#include - -#include "core/view/fx_offline.h" -#include "core/view/view_mode_model.h" // Flag / TrackFlagOp — what a park writes - -// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. -class MediaTrack; -class ReaProject; - -namespace reasampler { - -// -- may this chain be snapshotted? ------------------------------------------- -// -// A pre-park snapshot may only ever be taken from a chain NO park has touched. -// Its values are restore's sole source of truth, so a snapshot lifted from an -// already-parked chain records park state as the user's state, and every later -// restore then faithfully writes hidden/out-of-mix/FX-disabled back — permanent -// and silent. A model carrying no snapshot is NOT evidence the chain is clean; -// src/shell/view/CLAUDE.md lists the routes that still reach that pair. -// -// A snapshot has TWO halves and the test covers both, disjunctively: the flags -// can read clean while the FX are still offline (a hand recovery that stopped at -// the flags, one hand-offlined plugin) and vice versa. Either half reading -// parked poisons the whole snapshot, so either half alone refuses. - -// The REAPER track parameter each driven flag addresses. One home for the -// mapping — park's live read, restore's write and the trust test below must -// never disagree about which parameter a Flag names. -inline const char* trackFlagParm(Flag f) { - switch (f) { - case Flag::ShowInTcp: return "B_SHOWINTCP"; - case Flag::ShowInMixer: return "B_SHOWINMIXER"; - case Flag::MainSend: return "B_MAINSEND"; - case Flag::FxEnable: return "I_FXEN"; - } - return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet -} - -// Does the track already sit at every value this park would write? Then a park -// reached it. `readFlag` answers one Flag's live value; templating on it lets the -// live path compare as it reads (no per-track vector) while the tests drive the -// same fold from a table, keyed by Flag rather than by plan order. An empty plan -// is evidence of nothing and answers false. -template -bool parkFlagsAlreadyApplied(const std::vector& parkFlags, ReadFlag readFlag) { - if (parkFlags.empty()) return false; - for (const TrackFlagOp& op : parkFlags) - if (readFlag(op.flag) != op.value) return false; - return true; -} - -// DISJUNCTIVE fold — rationale is the snapshot-source invariant above (also -// src/shell/view/CLAUDE.md). -inline bool chainReadsParked(bool parkFlagsRead, bool anyFxOffline) { - return parkFlagsRead || anyFxOffline; -} - -enum class ParkAction { - SnapshotThenPark, // clean chain — capture the pre-park truth, then park - ParkOnly, // still parked; the held snapshot IS the pre-park truth - Refuse, // no snapshot AND a parked chain — the truth is gone -}; - -// Refusing leaves the track exactly as found: no flag write, no FX write, no -// snapshot. Nothing false is committed, so the moment the user's own edit puts -// the chain back somewhere trustworthy the next park captures it correctly — -// where a snapshot of the parked state would have closed that door for good. -inline ParkAction decidePark(bool haveSnapshot, bool chainReadsParked) { - if (haveSnapshot) return ParkAction::ParkOnly; - return chainReadsParked ? ParkAction::Refuse : ParkAction::SnapshotThenPark; -} - -// `decidePark` over the live track: the four driven flags AND the FX chain, which -// a snapshot taken here would record both halves of. Skips the reads on the -// ParkOnly path, where decidePark ignores the second term anyway. -ParkAction decideParkForTrack(MediaTrack* tr, bool haveSnapshot, - const std::vector& parkFlags); - -// "Track N" for an unnamed track (GetTrackName, matching capture::trackName's -// choice of API for the same reason). Exported so a caller holding the handle -// only briefly (view.cpp's park loop, before Undo_EndBlock2/TrackList_AdjustWindows -// invalidate it) can capture the name at resolve time rather than the handle. -std::string trackDisplayName(MediaTrack* tr); - -// The console report for the tracks one apply refused, empty when none. NAMES -// them: a refused track is hidden from both the TCP and the mixer, so a count -// alone points at nothing the user can go and find. It asserts no cause — the -// pair it detects has several routes (src/shell/view/CLAUDE.md), and only some of -// them are anyone's mistake. -inline std::string describeRefusedParks(const std::vector& trackNames) { - if (trackNames.empty()) return {}; - std::string msg = "ReaSampler: " + std::to_string(trackNames.size()) + " track" + - (trackNames.size() == 1 ? "" : "s") + - " left unparked -- no stored pre-park state, and a live state that " - "already reads as parked; parking now would record that as your own " - "state.\n"; - for (const std::string& name : trackNames) msg += " " + name + "\n"; - msg += "To bring a track back under mode control: restore its TCP and mixer visibility, " - "its main send and its FX enable, AND set every FX in its chain online -- FX enable " - "is the chain bypass and does not bring an individually offlined FX back. The next " - "mode switch will then park it correctly.\n"; - return msg; -} - -// Pure gate behind reportRefusedParks' one-per-changed-set memo: prints (i.e. -// updates the memo) on any change of owner or of the named set, stays silent on -// an exact repeat. An empty `now` still counts as a change from a non-empty -// `last` — that update itself prints nothing (describeRefusedParks({}) is -// empty) but resets the memo so a later refusal is never mistaken for a repeat -// of one that already healed. -inline bool shouldReport(bool sameOwnerAsLast, const std::vector& last, - const std::vector& now) { - return !(sameOwnerAsLast && last == now); -} - -// Prints describeRefusedParks for `refusedNames` (captured by the caller while its -// track handles are still fresh — see view.cpp's park loop), ONCE per changed set -// per project: the same refusal recurs on every reapply — every project open, -// every tag/untag — and repeating it verbatim is noise the user cannot act on any -// better the second time. Gated by shouldReport. -void reportRefusedParks(ReaProject* proj, const std::vector& refusedNames); - -// -- parking and restoring one track ------------------------------------------ -// -// Both run INSIDE applyMode's undo block, on the switch's own synchronous path, -// so one switch is one undo point. - -// A track parameter written only when it is not already there. A reapply -// re-plans every flag it already applied, so most of what it writes is a value -// the track already holds. The read is ASSUMED cheaper than the write it elides -// (unmeasured); what it certainly does is keep those no-op writes out of the -// undo record's mask. Returns whether it wrote. -bool writeIfChanged(MediaTrack* tr, const char* parm, double value); - -// Is `tr` still a live track of `proj` (nullptr = current project)? The apply's -// loops re-ask per track: a plugin unloading is assumed to pump the message -// loop, so a handle resolved before the first FX write can be dead by the last. -// `proj` is resolved to a concrete pointer here because ValidatePtr2 documents -// no nullptr-means-current-project contract (reaper_plugin_functions.h:7899), -// and a false there would silently skip every track. -bool trackStillLive(ReaProject* proj, MediaTrack* tr); - -// What one track's park or restore did, folded into the apply's block state. -struct TrackApplyResult { - bool wrote = false; // a project write or a snapshot moved — the undo-point verdict - bool fxMoved = false; // ⇒ the block's mask owes UNDO_STATE_FX - bool refused = false; // park only: the chain was left exactly as found - - // Restore only: captured FX state resolveFxRestore could not place. Reported - // rather than swallowed — silence would read as "restore worked" while an FX - // sat at whatever state the park left it in. - FxRestoreDrops drops; -}; - -// Parks one inactive leaf: the trust decision (decidePark), the pre-park -// snapshot when it is owed, then flags before FX so the track is out of the mix -// and bypassed before its plugins start unloading. On `refused` nothing at all -// was written — the caller names the track (trackDisplayName) while its handle -// is still fresh. -TrackApplyResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& flags); - -// Restores one returning leaf: FX before flags, park's order mirrored, so the -// chain comes back online while still bypassed and out of the mix and the track -// never sounds through a half-restored chain. Consumes the snapshot — from that -// point the flags are back at their captured values, and a later reapply must -// not replan a restore over whatever the user has changed since. -TrackApplyResult restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, - const std::vector& fxOps, - const std::vector& flags); - -// One console line for a whole apply's drops. Called after the undo block -// closes: a report is not a project write and must not join what a Ctrl-Z rolls -// back. -void reportFxRestoreDrops(const FxRestoreDrops& drops, int trackCount); - -} // namespace reasampler diff --git a/src/shell/view/view_lanes.cpp b/src/shell/view/view_lanes.cpp new file mode 100644 index 0000000..ae3dbda --- /dev/null +++ b/src/shell/view/view_lanes.cpp @@ -0,0 +1,360 @@ +// See view_lanes.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). +// The lane decisions are pure (planToggle / planLaneMinting, core/view); this file +// owns the live lane reads and writes they resolve against. + +#include "shell/view/view_lanes.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "core/view/lane_keys.h" +#include "core/view/view_tree.h" +#include "shell/capture/item_read.h" +#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_UpdateArrange +#define REAPERAPI_WANT_UpdateTimeline +#define REAPERAPI_WANT_CountTrackMediaItems +#define REAPERAPI_WANT_GetTrackMediaItem +#define REAPERAPI_WANT_GetMediaItemInfo_Value +#define REAPERAPI_WANT_SetMediaItemInfo_Value +#include "reaper_plugin_functions.h" + +namespace reasampler { + +using view::buildFolderTree; +using view::isOnManualLane; +using view::managedLaneKey; +using view::modeIdFromLaneName; +using view::TrackFolderEntry; + +namespace { + +// I_FREEMODE value for fixed lanes. SDK: 0=normal, 1=free item positioning, 2=fixed lanes. +constexpr int kFreeModeFixedLanes = 2; + +// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane track +// showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist but displays +// as non-fixed-lane). +constexpr int kLanesDisplayAsNormal = 2; + +// C_LANESETTINGS &32 = hide per-lane buttons; OR'd in, never clobbering the mask. +// Deliberately NOT setting &1 (auto-remove empty lanes): the lazy-mint decision never +// mints an empty lane, so &1 buys nothing and risks REAPER silently removing a +// managed lane out from under the ownership index. +constexpr int kLaneSettingsHideButtons = 32; + +// Makes a tool-split track's display read as an ordinary track. Gated by every caller +// on the tool-driven transition INTO fixed lanes (freeMode != 2 before the flip) — a +// track already in fixed-lane mode (the user's own) never reaches this, so a user's +// comp-lane display prefs are never stomped. +void applyTransparentLaneDisplay(MediaTrack* tr) { + SetMediaTrackInfo_Value(tr, "C_LANESCOLLAPSED", + static_cast(kLanesDisplayAsNormal)); + const int settings = static_cast(GetMediaTrackInfo_Value(tr, "C_LANESETTINGS")); + SetMediaTrackInfo_Value(tr, "C_LANESETTINGS", + static_cast(settings | kLaneSettingsHideButtons)); +} + +// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed / +// unavailable (non-fixed-lane track). +std::string laneName(MediaTrack* tr, int laneIdx) { + char parm[32]; + std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); + char buf[512] = {0}; + if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {}; + return std::string(buf); +} + +// Managed lane durable key -> current ordinal on `tr`. Manual lanes are omitted, so a +// key absent from the map must not be driven. +std::map managedLaneOrdinals(MediaTrack* tr) { + std::map byKey; + const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); + for (int lane = 0; lane < numLanes; ++lane) { + std::optional key = managedLaneKey(laneName(tr, lane)); + if (key) byKey.emplace(*key, lane); // first ordinal wins if names collide + } + return byKey; +} + +// Track-side C_LANEPLAYS:N alone hides+silences every item on lane N (SDK: item-side +// C_LANEPLAYS is read-only, so no per-item write exists or is needed). +// B_FIXEDLANE_HIDDEN is also read-only — hide/show follows from C_LANEPLAYS=0/1, +// never written directly. +bool applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) { + char parm[32]; + std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx); + return writeIfChanged(tr, parm, static_cast(lanePlays)); +} + +// One pass, so the assign loop needs no per-item re-scan. +std::map itemHandlesByGuid(MediaTrack* tr) { + std::map byGuid; + const int itemCount = CountTrackMediaItems(tr); + for (int i = 0; i < itemCount; ++i) { + MediaItem* it = GetTrackMediaItem(tr, i); + if (!it) continue; + std::string ig = itemGuid(it); + if (!ig.empty()) byGuid.emplace(std::move(ig), it); + } + return byGuid; +} + +// An untagged item is Arrange by default (mirrors leafBelongsToMode). A +// show-both/multi-mode item resolves to its first mode id — unusual for lane content, +// and any one mode is sufficient for the decision. +std::string itemModeFromMembership(const ViewModeModel& model, const std::string& itemGuid) { + const std::set modes = model.membership().modesOf(itemGuid); + if (modes.empty()) return kArrangeModeId; + return *modes.begin(); +} + +// The per-track LaneItem picture the pure decision consumes. Manual-lane reads are +// skipped on a non-fixed-lane track (isOnManualLane is false there anyway). +std::vector readLaneTracks(const ViewModeModel& model, + const TrackHandles& handleByGuid) { + std::vector tracks; + tracks.reserve(handleByGuid.size()); + for (const auto& [guid, tr] : handleByGuid) { + LaneTrack lt; + lt.trackGuid = guid; + + const bool fixedLane = + static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes; + + const int itemCount = CountTrackMediaItems(tr); + lt.items.reserve(static_cast(itemCount)); + for (int i = 0; i < itemCount; ++i) { + MediaItem* it = GetTrackMediaItem(tr, i); + if (!it) continue; + const std::string ig = itemGuid(it); + if (ig.empty()) continue; + LaneItem li; + li.guid = ig; + li.modeId = itemModeFromMembership(model, ig); + const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{}; + li.onManualLane = isOnManualLane(fixedLane, ln); + lt.items.push_back(std::move(li)); + } + tracks.push_back(std::move(lt)); + } + return tracks; +} + +// Idempotent, and non-destructive: only this reversible flag is written, never a move +// in time or across tracks. +bool assignItemToLane(MediaTrack* tr, MediaItem* it, int laneOrdinal) { + const int current = static_cast(GetMediaItemInfo_Value(it, "I_FIXEDLANE")); + if (current == laneOrdinal) return false; // already there — no-op + SetMediaItemInfo_Value(it, "I_FIXEDLANE", static_cast(laneOrdinal)); + return true; +} + +// Applies the pure LaneMintPlan. Returns true if any project write actually changed +// state (⇒ caller keeps the Undo block and refreshes the timeline). +// +// The plan only ever names managed-prefixed lanes and only ever assigns +// managed-eligible items; I_NUMFIXEDLANES is only ever GROWN, never shrunk, so a +// user's existing manual lanes are never renamed or reassigned. +bool applyMintPlan(ViewModeModel& model, const LaneMintPlan& plan, + const TrackByGuid& handleByGuid) { + bool changed = false; + + std::map> mintsByTrack; + for (const LaneMint& m : plan.mints) mintsByTrack[m.trackGuid].push_back(&m); + std::map> assignsByTrack; + for (const LaneAssign& a : plan.assigns) assignsByTrack[a.trackGuid].push_back(&a); + + for (const LaneMintPlan::TrackSplit& split : plan.splits) { + MediaTrack* tr = resolve(handleByGuid, split.trackGuid); + if (!tr) continue; // stale GUID — prune + + // A track not already in fixed-lane mode is one the tool is splitting now, so + // it owns the display; a track already at I_FREEMODE==2 (the user's own, or a + // prior tool run) skips this and keeps its display prefs. + const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); + if (freeMode != kFreeModeFixedLanes) { + SetMediaTrackInfo_Value(tr, "I_FREEMODE", static_cast(kFreeModeFixedLanes)); + applyTransparentLaneDisplay(tr); + changed = true; + } + + // Grow-only: a track may already carry the user's manual lanes, so the lane + // count only ever increases; managed lanes occupy the tail ordinals. + int laneCount = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); + + std::map present = managedLaneOrdinals(tr); + + for (const LaneMint* m : mintsByTrack[split.trackGuid]) { + model.lanes().setManaged(m->trackGuid, m->laneKey, m->modeId); // ownership + if (present.count(m->laneKey)) continue; // already minted — idempotent + + const int laneIdx = laneCount++; + SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", static_cast(laneCount)); + char parm[32]; + std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx); + std::vector name(m->laneKey.begin(), m->laneKey.end()); + name.push_back('\0'); + GetSetMediaTrackInfo_String(tr, parm, name.data(), true); + present.emplace(m->laneKey, laneIdx); // now resolvable for the assign pass + changed = true; + } + + const std::map ordinals = managedLaneOrdinals(tr); + const std::map itemsByGuid = itemHandlesByGuid(tr); + for (const LaneAssign* a : assignsByTrack[split.trackGuid]) { + auto ord = ordinals.find(a->laneKey); + if (ord == ordinals.end()) continue; // key not live — prune, never mis-assign + auto handle = itemsByGuid.find(a->itemGuid); + if (handle == itemsByGuid.end()) continue; // stale item GUID — prune + if (assignItemToLane(tr, handle->second, ord->second)) changed = true; + } + } + return changed; +} + +} // namespace + +LaneApplyResult applyLaneOps(const TrackByGuid& handleByGuid, + const std::vector& lanes) { + LaneApplyResult out; + if (lanes.empty()) return out; + + std::map> byTrack; + for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op); + + for (const auto& [guid, ops] : byTrack) { + MediaTrack* tr = resolve(handleByGuid, guid); + if (!tr) continue; // stale GUID — prune + + // Every track reaching here already owns a managed lane (planToggle only emits + // ops for managed lanes), so re-asserting fixed-lane mode is always a + // tool-driven (re)split — never a user's untouched manual-fixed-lane track — + // and gets the same transparent display. + const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); + if (freeMode != kFreeModeFixedLanes) { + SetMediaTrackInfo_Value(tr, "I_FREEMODE", + static_cast(kFreeModeFixedLanes)); + applyTransparentLaneDisplay(tr); + out.freeMode = true; + out.wrote = true; + } + + const std::map ordinals = managedLaneOrdinals(tr); + for (const LanePlayOp* op : ops) { + auto it = ordinals.find(op->laneKey); + if (it == ordinals.end()) continue; // key not live on this track — prune + if (applyLanePlays(tr, it->second, op->lanePlays)) out.wrote = true; + } + } + return out; +} + +bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) { + TrackHandles handleByGuid; + std::vector entries = readFolderEntries(proj, handleByGuid); + // The tree is needed to detect a content-bearing folder derived-visible in + // >1 mode, exactly as applyMode builds it. + const FolderTree tree = buildFolderTree(entries); + + const std::vector tracks = readLaneTracks(model, handleByGuid); + const LaneMintPlan plan = planLaneMinting(model, tree, tracks); + if (plan.empty()) return false; // nothing to mint — no Undo point for a no-op tick + + const TrackByGuid trackByGuid = indexHandles(handleByGuid); + + 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); + + 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) { + // 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 + // nothing even though the item needs to appear there now. UpdateArrange() + // alone (no I_FREEMODE change happened, so UpdateTimeline isn't owed) is a + // repaint, not a mutation — stays outside the undo block. + UpdateArrange(); + } + return changed; +} + +void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj) { + TrackHandles handleByGuid; + readFolderEntries(proj, handleByGuid); // populates handleByGuid (tree unused here) + + // Pure read of REAPER state (no lane created, no I_FREEMODE/I_NUMFIXEDLANES/ + // I_FIXEDLANE written) plus an ownership-index write, recovering managed + // classification from the durable name. An unprefixed lane is left alone. + for (const auto& [guid, tr] : handleByGuid) { + const int freeMode = static_cast(GetMediaTrackInfo_Value(tr, "I_FREEMODE")); + if (freeMode != kFreeModeFixedLanes) continue; // no fixed lanes ⇒ nothing managed + + const int numLanes = static_cast(GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES")); + for (int lane = 0; lane < numLanes; ++lane) { + const std::string name = laneName(tr, lane); + std::optional key = managedLaneKey(name); + if (!key) continue; // manual/unnamed lane — leave off the index + std::optional mode = modeIdFromLaneName(name); + if (!mode) continue; // prefix-only/illegal name — skip defensively + + // A mode id encoded in the name may no longer be registered (e.g. removed + // since the project was saved). Recording it managed would make the toggle + // planner drive a lane keyed to a mode that can never be active — + // permanently silenced, orphaning its items. So skip: the lane stays off + // the index (manual-by-default) but keeps its name, so a later + // re-registration of the mode heals cleanly. + if (!model.modes().contains(*mode)) continue; + model.lanes().setManaged(guid, *key, *mode); + } + } +} + +} // namespace reasampler diff --git a/src/shell/view/view_lanes.h b/src/shell/view/view_lanes.h new file mode 100644 index 0000000..ca0bdd5 --- /dev/null +++ b/src/shell/view/view_lanes.h @@ -0,0 +1,51 @@ +#pragma once +// Managed fixed lanes, REAPER side: minting one lane per mode on a track whose own +// content spans modes, driving the active mode's lane audible and every other +// managed lane silent+hidden, and recovering ownership from the durable P_LANENAME +// on project open. Manual lanes are never minted, driven, renamed or reassigned. + +#include + +#include "core/view/view_mode_model.h" +#include "shell/view/view_tracks.h" + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class ReaProject; + +namespace reasampler { + +// `freeMode` alone decides whether UpdateTimeline is owed (SDK: required after an +// 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; +}; + +// 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 — 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 — +// self-healing, without minting/reassigning anything and without touching +// membership. A lane without the managed prefix is left untouched (manual). +// `proj` == nullptr means the current project. +void reconcileManagedLanes(ViewModeModel& model, ReaProject* proj); + +} // namespace reasampler diff --git a/src/shell/view/view_park.cpp b/src/shell/view/view_park.cpp new file mode 100644 index 0000000..a65da1b --- /dev/null +++ b/src/shell/view/view_park.cpp @@ -0,0 +1,178 @@ +// See view_park.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). + +#include "shell/view/view_park.h" + +#include // strnlen — bounded read of GetTrackName's buffer +#include +#include + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_GetTrackName +#define REAPERAPI_WANT_SetMediaTrackInfo_Value +#define REAPERAPI_WANT_ShowConsoleMsg +#define REAPERAPI_WANT_TrackFX_GetCount +#define REAPERAPI_WANT_TrackFX_GetFXGUID +#define REAPERAPI_WANT_TrackFX_GetOffline +#define REAPERAPI_WANT_guidToString +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// Captures prior driven-flag state, and the chain's per-FX offline state, before +// parking. Never reads B_MUTE/I_SOLO; ints preserve whatever REAPER reported +// (TrackSnapshot's defensive contract). The plain 0..TrackFX_GetCount-1 enumeration +// — never the 0x1000000/0x2000000 input-FX or container forms — is the same one the +// reclaimer resolves against. `fxKeying` stays Identity: a live read always knows +// the chain. +TrackSnapshot snapshotTrack(MediaTrack* tr) { + TrackSnapshot snap; + auto read = [tr](Flag f) { + return static_cast(GetMediaTrackInfo_Value(tr, trackFlagParm(f))); + }; + snap.showInTcp = read(Flag::ShowInTcp); + snap.showInMixer = read(Flag::ShowInMixer); + snap.mainSend = read(Flag::MainSend); + snap.fxEnable = read(Flag::FxEnable); + + const int fxCount = TrackFX_GetCount(tr); + snap.fxOffline.reserve(static_cast(fxCount)); + for (int fx = 0; fx < fxCount; ++fx) + snap.fxOffline.push_back( + FxOfflineState{fxGuidString(tr, fx), TrackFX_GetOffline(tr, fx) ? 1 : 0}); + return snap; +} + +bool applyFlags(MediaTrack* tr, const std::vector& flags) { + bool wrote = false; + for (const TrackFlagOp& op : flags) + if (writeIfChanged(tr, trackFlagParm(op.flag), static_cast(op.value))) + wrote = true; + return wrote; +} + +// The visible/active mirror of a park write: same four ops, value 1 instead of +// whatever the plan says (always 0 for a park plan — see makeParkPlan). Reusing +// the incoming park ops rather than minting a separate "expose plan" keeps the +// two writes provably addressing the same four parameters. +bool exposeFlags(MediaTrack* tr, const std::vector& parkFlags) { + bool wrote = false; + for (const TrackFlagOp& op : parkFlags) + if (writeIfChanged(tr, trackFlagParm(op.flag), 1.0)) wrote = true; + return wrote; +} + +// The last EXPOSE report, so an unchanged set is not reprinted on every reapply +// the track hasn't yet self-healed from. +ReaProject* g_reportedOwner = nullptr; +std::vector g_reportedExposed; + +} // namespace + +const int kViewUndoMask = UNDO_STATE_TRACKCFG | UNDO_STATE_ITEMS | UNDO_STATE_MISCCFG; + +// Lifetime is settled: the string copy is taken immediately and the GUID* is never held +// past this call (reaper_plugin_functions.h:7348 documents no null contract for +// TrackFX_GetFXGUID; treating null as "no identity" is the safe read). +// +// [verify — DAW] STABILITY across a chain mutation is not settled the same way: confirm +// the GUID for one FX instance survives a native drag-reorder, an SWS move +// (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not follow the +// plugin after that call, i.e. wrong-plugin restores or mass drops through fx_offline on +// that path specifically), a save/reload round trip, and two live instances of one plugin +// type staying distinguishable. See src/shell/view/CLAUDE.md's Gotchas for the SWS-path +// risk this leaves open. +std::string fxGuidString(MediaTrack* tr, int fx) { + GUID* g = TrackFX_GetFXGUID(tr, fx); + if (!g) return {}; + char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract) + guidToString(g, buf); + return std::string(buf); +} + +bool writeIfChanged(MediaTrack* tr, const char* parm, double value) { + if (GetMediaTrackInfo_Value(tr, parm) == value) return false; + SetMediaTrackInfo_Value(tr, parm, value); + return true; +} + +TrackParkResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, + const std::vector& flags) { + TrackParkResult result; + + if (!model.holdsSnapshotFlags(guid)) { + const TrackSnapshot* prior = model.snapshot(guid); + + // No record of ANY kind (see parkTrack's header doc for why prior == nullptr + // is the right gate): the ambiguous case. A live chain already reading + // park-shaped is EXPOSEd — handed back visible, no snapshot stored — rather + // than adopted as the user's intent. + if (!prior) { + auto read = [tr](Flag f) { + return static_cast(GetMediaTrackInfo_Value(tr, trackFlagParm(f))); + }; + if (chainReadsParked(flags, read)) { + if (exposeFlags(tr, flags)) result.wrote = true; + result.exposed = true; + return result; // this cycle hands the track back; it does not park it + } + } + + TrackSnapshot snap = snapshotTrack(tr); + + // A restore hands the flags back immediately but leaves the chain offline + // until the reclaimer gets to it, so a re-park lands here with the FX half + // still held over a still-parked chain. Union the live read against the + // record rather than overwriting either way — see unionFxOfflineOnRepark. + // + // `prior->fxHeld` is provably redundant here today — eraseIfSpent + // (view_mode_model.cpp) guarantees a surviving record has fxHeld == true + // — so the false arm is unreached. Kept anyway: it is the direct + // statement of what this branch actually needs, and cheaper to keep true + // than to re-derive if that guarantee ever loosens. + if (prior && prior->fxHeld && prior->fxKeying == FxKeying::Identity) { + snap.fxOffline = unionFxOfflineOnRepark(prior->fxOffline, snap.fxOffline); + snap.fxKeying = FxKeying::Identity; + } else if (prior && prior->fxHeld) { + // Slot-keyed legacy record (lifted from a pre-identity view_state): no + // identities to union against, so fall back to the old behavior and + // trust the record verbatim over live slots that may have moved since. + snap.fxOffline = prior->fxOffline; + snap.fxKeying = prior->fxKeying; + } + model.storeSnapshot(guid, snap); + } + + if (applyFlags(tr, flags)) result.wrote = true; + return result; +} + +bool restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, + const std::vector& flags) { + const bool wrote = applyFlags(tr, flags); + model.releaseSnapshotFlags(guid); + return wrote; +} + +std::string trackDisplayName(MediaTrack* tr) { + std::vector buf(1024, '\0'); + if (!GetTrackName || !GetTrackName(tr, buf.data(), static_cast(buf.size()))) + return "(name unavailable)"; + return std::string(buf.data(), strnlen(buf.data(), buf.size())); +} + +void reportExposedTracks(ReaProject* proj, const std::vector& trackNames) { + if (!shouldReport(proj == g_reportedOwner, g_reportedExposed, trackNames)) return; + g_reportedOwner = proj; + g_reportedExposed = trackNames; + + const std::string msg = describeExposedTracks(trackNames); + // "!SHOW:" so an unattended project-load reapply cannot force the console open + // (reaper_plugin_functions.h:6536). + if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str()); +} + +} // namespace reasampler diff --git a/src/shell/view/view_park.h b/src/shell/view/view_park.h new file mode 100644 index 0000000..78d0106 --- /dev/null +++ b/src/shell/view/view_park.h @@ -0,0 +1,182 @@ +#pragma once +// Design View's park surface: one track's flag park or restore, plus the park +// record that makes the restore non-destructive. FLAG DOMAIN ONLY — a park record's +// FX half is captured here (a read) but never written here; the reclaimer owns +// every TrackFX_SetOffline, off the switch's synchronous path. + +#include +#include +#include + +#include "core/view/view_mode_model.h" // Flag / TrackFlagOp — what a park writes + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class MediaTrack; +class ReaProject; + +namespace reasampler { + +// The REAPER track parameter each driven flag addresses. One home for the mapping — +// the park's pre-park read, the restore's write and the reconcile's repair must +// never disagree about which parameter a Flag names. +inline const char* trackFlagParm(Flag f) { + switch (f) { + case Flag::ShowInTcp: return "B_SHOWINTCP"; + case Flag::ShowInMixer: return "B_SHOWINMIXER"; + case Flag::MainSend: return "B_MAINSEND"; + case Flag::FxEnable: return "I_FXEN"; + } + return "B_SHOWINTCP"; // unreachable; keeps the compiler quiet +} + +// The FX's own durable identity, braced exactly like the track GUID keys, empty when +// REAPER reports none. The ONE such read: the park's snapshot and the reclaimer's +// resolve must address the same chain by the same names, or a restore lands one +// plugin's state on another. See its definition for the stability caveat. +std::string fxGuidString(MediaTrack* tr, int fx); + +// A re-park's FX half, unioned rather than overwritten: keeps every FX identity +// `prior` already names (the user's real pre-park state, still offline while the +// reclaimer hasn't run) and appends whatever identity `live` sees that `prior` +// does not — a plugin added during the restored interval, which a verbatim keep +// of `prior` would otherwise never capture and leave stuck offline for good once +// the reclaimer lands. Only meaningful when `prior` is Identity-keyed; a +// Slot-keyed legacy record has no identities to union against, so parkTrack falls +// back to trusting it verbatim instead of calling this. An empty fxGuid (REAPER +// reported no identity) is never deduped against another empty one — it is +// unrestorable either way, so appending it costs nothing and dedup would be a +// coin flip. +inline std::vector unionFxOfflineOnRepark( + const std::vector& prior, const std::vector& live) { + std::vector merged = prior; + std::set named; + for (const FxOfflineState& fx : merged) + if (!fx.fxGuid.empty()) named.insert(fx.fxGuid); + for (const FxOfflineState& fx : live) + if (fx.fxGuid.empty() || named.insert(fx.fxGuid).second) merged.push_back(fx); + return merged; +} + +// Does the track already sit at every value `parkFlags` would write? Templated on +// the read so live code compares as it reads (one call per Flag, no live-value +// vector) while a test drives the same fold from a table. FX-offline is +// deliberately NOT folded in: Wave 3 moved every per-FX write off this path onto +// the reclaimer, so a chain with these four flags fine but an FX hand-offlined is +// not evidence THIS path ever touched the track — only the flags it would itself +// have driven are. An empty plan is evidence of nothing and answers false. +template +bool chainReadsParked(const std::vector& parkFlags, ReadFlag readFlag) { + if (parkFlags.empty()) return false; + for (const TrackFlagOp& op : parkFlags) + if (readFlag(op.flag) != op.value) return false; + return true; +} + +// The undo-state domains both the mode-switch apply (view.cpp) and lane minting +// (view_lanes.cpp) write: track config/routing, item lane assignment, and the +// ext-state block (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, +// and no UNDO_STATE_FX at all — neither writer moves FX state, so carrying the +// project's whole FX chunk on every point would cost with the plugin count for +// nothing. One symbol, not two identically-valued constants free to diverge. +// [verify — DAW] INFERRED, not documented: UNDO_STATE_TRACKCFG reads "track/master +// vol/pan/routing" (reaper_plugin.h:1541) and names neither the fixed-lane +// properties (assumed to ride UNDO_STATE_ITEMS, :1543) nor +// B_SHOWINTCP/B_SHOWINMIXER — and for the visibility pair that inference is now +// DISPROVEN in the DAW: REAPER's undo does not restore them, which is what +// view_reconcile exists to repair. +extern const int kViewUndoMask; + +// A track parameter written only when it is not already there. A reapply re-plans +// every flag it already applied, so most of what it writes is a value the track +// already holds. The read is ASSUMED cheaper than the write it elides (unmeasured); +// what it certainly does is keep those no-op writes out of the undo record's mask. +// 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 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. +struct TrackParkResult { + bool wrote = false; + bool exposed = false; +}; + +// Parks one inactive leaf: capture the pre-park truth when the model does not +// already hold ANY record for it (holdsSnapshotFlags false AND model.snapshot == +// nullptr), then drive the flags. +// +// A record with a held FX half but no held flag half (a re-park landing right +// after a restore, before the reclaimer catches up) is NOT the no-record case — +// model.snapshot(guid) is non-null there, so it takes the existing union path +// below, never the branch this comment is about. That is how EXPOSE is known not +// to fire on a track the tool legitimately still holds a record for: EXPOSE is +// gated on snapshot(guid) == nullptr, and a legitimately-held record is by +// definition non-null (eraseIfSpent only drops a record once BOTH halves are +// spent). +// +// The true no-record case splits on whether the live chain already reads +// park-shaped (chainReadsParked, flags only — see its own comment for why FX is +// excluded). Not park-shaped: the chain is the track's own honest state, captured +// and parked exactly as ever. Park-shaped: parking now would adopt a park with no +// account behind it — an undo/redo or reload that dropped the snapshot, a hand or +// script edit, or simply a track the user keeps hidden — as the user's intent, +// which either wipes a hand repair on the very next restore or hides an untouched +// track for good. EXPOSE hands it back instead: no snapshot stored, the four +// flags driven to visible/active, nothing reported here (the caller batches it). +// The next apply sees an honest chain again and captures/parks it normally — +// self-healing, no user action required. +TrackParkResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr, + const std::vector& flags); + +// GetTrackName, matching capture::trackName's reason for the same choice: "Track +// N" for an unnamed track, matching REAPER's own convention. Exported so a caller +// holding the handle only briefly (view.cpp's park loop, before +// Undo_EndBlock2/TrackList_AdjustWindows can invalidate it) captures the name at +// resolve time rather than the handle. +std::string trackDisplayName(MediaTrack* tr); + +// One console message for the tracks EXPOSEd this apply, empty when none. NAMES +// them: an exposed track was hidden from both the TCP and the mixer moments ago, +// so a bare count points at nothing the user can go and find. Says the tool lost +// its own record and handed the track back — never "refused". +inline std::string describeExposedTracks(const std::vector& trackNames) { + if (trackNames.empty()) return {}; + const bool one = trackNames.size() == 1; + std::string msg = "ReaSampler: " + std::to_string(trackNames.size()) + " track" + + (one ? "" : "s") + " handed back visible -- the tool's record of " + + (one ? "its" : "their") + + " pre-park state is gone (an undo/redo or a project reload dropped " + "it), and parking now would have recorded the parked state as " + "yours instead.\n"; + for (const std::string& name : trackNames) msg += " " + name + "\n"; + msg += "The next mode switch will park it correctly from what you see now.\n"; + return msg; +} + +// Pure gate behind reportExposedTracks' one-per-changed-set memo: true on any +// change of owner or of the named set, false on an exact repeat. +inline bool shouldReport(bool sameOwnerAsLast, const std::vector& last, + const std::vector& now) { + return !(sameOwnerAsLast && last == now); +} + +// Prints describeExposedTracks for `trackNames` (captured by the caller while its +// track handles are still fresh — see view.cpp's park loop), ONCE per changed set +// per project: the same exposure recurs on every reapply until the track +// self-heals, and repeating it verbatim is noise the user cannot act on any +// better the second time. `proj` is used only as a memo key, never dereferenced. +void reportExposedTracks(ReaProject* proj, const std::vector& trackNames); + +// Restores one returning leaf from its record and spends the record's flag half. +// 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); + +} // namespace reasampler diff --git a/src/shell/view/view_reconcile.cpp b/src/shell/view/view_reconcile.cpp new file mode 100644 index 0000000..0acb8a0 --- /dev/null +++ b/src/shell/view/view_reconcile.cpp @@ -0,0 +1,80 @@ +// See view_reconcile.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). +// The decision is pure (header, test_view_reconcile.cpp); this file owns the memo's +// lifetime and the writes. + +#include "shell/view/view_reconcile.h" + +#include +#include + +#include "shell/view/view_park.h" // trackFlagParm / writeIfChanged +#include "shell/view/view_tracks.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_TrackList_AdjustWindows +#define REAPERAPI_WANT_UpdateArrange +#define REAPERAPI_WANT_ValidatePtr2 +#include "reaper_plugin_functions.h" + +namespace reasampler { + +namespace { + +// What the last apply left driven, per project. Keyed by ReaProject*, COMPARE-ONLY +// and never dereferenced (the same discipline the panel's own project memos take): a +// closed project's pointer must never be followed, only found unequal or used as a +// map key. A project absent here gets no repair — the tool has no account of driving +// anything there, so it has no opinion. Never pruned on project close: churn is a +// handful of entries per session, cheap enough that a close hook would cost more than +// it saves. One slot used to serve every project; a tab-switch mid-session silently +// discarded whichever project's memo wasn't "current" — see planReconcilePerProject. +std::map g_applied; + +} // namespace + +void noteAppliedState(ReaProject* proj, const ViewModeModel& model) { + ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0); + g_applied[project] = view::appliedState(model); +} + +void reconcileAppliedFlags(ViewModeModel& model, ReaProject* proj) { + ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0); + if (!ValidatePtr2(nullptr, project, "ReaProject*")) return; + + const view::AppliedState reloaded = view::appliedState(model); + const ProjectReconcileResult result = planReconcilePerProject(g_applied, project, reloaded); + + // Adopted whether or not anything is written: from here the memo describes the + // project as it now stands, so a second reload cannot replay a repair that has + // already happened over values the user has since changed. + g_applied[project] = result.newEntry; + if (result.ops.empty()) return; + + TrackHandles handleByGuid; + 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; + { + UiRefreshHold uiHold; + for (const TrackFlagOp& op : result.ops) { + MediaTrack* tr = resolve(trackByGuid, op.guid); + if (!tr) continue; // stale/deleted GUID — prune + if (writeIfChanged(tr, trackFlagParm(op.flag), static_cast(op.value))) + wrote = true; + } + } + + if (wrote) { + TrackList_AdjustWindows(false); // tracks reappearing owe the full relayout + UpdateArrange(); + } +} + +} // namespace reasampler diff --git a/src/shell/view/view_reconcile.h b/src/shell/view/view_reconcile.h new file mode 100644 index 0000000..07caa33 --- /dev/null +++ b/src/shell/view/view_reconcile.h @@ -0,0 +1,93 @@ +#pragma once +// The repair pass for a project state REAPER reloaded under us. An undo/redo rolls +// the view_state ext block back but NOT B_SHOWINTCP/B_SHOWINMIXER — UNDO_STATE_TRACKCFG +// does not carry them (DAW-confirmed) — so a park the model no longer records can be +// left driven on the track with nobody holding its captured values but us. + +#include +#include +#include +#include + +#include "core/view/applied_state.h" +#include "core/view/view_mode_model.h" + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class ReaProject; + +namespace reasampler { + +// The flags to hand back, and to whom. +// +// SCOPE — the rule this pass exists to keep: a track is driven only where a park +// record names it, and the value written is one that record captured. `applied` is +// the tool's account of an act it PERFORMED, so a track absent from it (and from the +// reloaded model) is one the tool never drove, holds no captured value for, and has +// no opinion about. Keying the repair on "the model calls this track active" instead +// is the rejected shape: it asserts B_MAINSEND=1 on a track the user disabled by hand. +// +// A track the RELOADED model still parks is left out too — it still has an owner, and +// the reapply that follows this pass drives it from the live model. +// +// THE LOAD-BEARING RULE is narrower than the `applied == reloaded` early-out below +// looks: only a GUID in `applied.parked` that is ABSENT from `reloaded.parked` can +// ever produce an op. A drifted `activeModeId` alone, or a drifted captured VALUE +// under a GUID both sides still name, fails the equality check (so the early-out is +// skipped) but the loop below finds that GUID still recorded and writes nothing — +// the check is a cheap skip, not a "did anything change" decision. See +// tests/test_view_reconcile.cpp's drift-alone case for the documented inertness. +inline std::vector planReconcile(const view::AppliedState& applied, + const view::AppliedState& reloaded) { + std::vector ops; + if (applied == reloaded) return ops; // no drift in what we applied — nothing to repair + + std::set stillRecorded; + for (const view::AppliedTrack& t : reloaded.parked) stillRecorded.insert(t.guid); + + for (const view::AppliedTrack& t : applied.parked) { + if (stillRecorded.count(t.guid)) continue; + ops.push_back(TrackFlagOp{t.guid, Flag::ShowInTcp, t.showInTcp}); + ops.push_back(TrackFlagOp{t.guid, Flag::ShowInMixer, t.showInMixer}); + ops.push_back(TrackFlagOp{t.guid, Flag::MainSend, t.mainSend}); + ops.push_back(TrackFlagOp{t.guid, Flag::FxEnable, t.fxEnable}); + } + return ops; +} + +// The per-project memo's pure decision: given every project the tool has a memo for +// so far, which project just reloaded, and its fresh fingerprint, produce the ops to +// write back plus the entry the memo should hold for that project from here on. +// `ReaProject*` is used only as an opaque map key here — never dereferenced, the same +// compare-only discipline the .cpp's memo itself keeps. +// +// A project absent from `applied` gets no repair (see SCOPE above: no memo, no +// opinion), but its `reloaded` fingerprint is still the entry handed back — a project +// this pass has never seen before starts its own history here rather than being +// re-skipped on every future compare too. +struct ProjectReconcileResult { + std::vector ops; + view::AppliedState newEntry; +}; + +inline ProjectReconcileResult planReconcilePerProject( + const std::map& applied, ReaProject* project, + const view::AppliedState& reloaded) { + auto it = applied.find(project); + ProjectReconcileResult result; + result.ops = it != applied.end() ? planReconcile(it->second, reloaded) + : std::vector{}; + result.newEntry = reloaded; + return result; +} + +// Records what `model` left driven in `proj` (nullptr = current project). Called at +// the end of every apply: the fingerprint is the ONLY account of what the tool drove +// 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. 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/src/shell/view/view_solo.h b/src/shell/view/view_solo.h index 7e4d5bb..cdb4241 100644 --- a/src/shell/view/view_solo.h +++ b/src/shell/view/view_solo.h @@ -6,21 +6,13 @@ #include #include #include -#include #include #include "core/view/solo_cache.h" - -// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. -class MediaTrack; +#include "shell/view/view_tracks.h" // TrackHandles — the enumeration these writers pass over namespace reasampler { -// The (GUID, handle) enumeration applyMode builds from GetTrack. GetTrack's index -// space excludes the master track, so no solo read or write reachable through this -// type can ever land on master. -using TrackHandles = std::vector>; - std::vector readTrackSolos(const TrackHandles& handles); // Applies each op to its live handle in ONE pass over `handles`; an op naming a GUID diff --git a/src/shell/view/view_tracks.cpp b/src/shell/view/view_tracks.cpp new file mode 100644 index 0000000..89f221e --- /dev/null +++ b/src/shell/view/view_tracks.cpp @@ -0,0 +1,47 @@ +// See view_tracks.h. Compiled into the reaper_reasampler module; includes +// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). + +#include "shell/view/view_tracks.h" + +#include + +#include "shell/capture/track_guid.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_CountTracks +#define REAPERAPI_WANT_GetTrack +#define REAPERAPI_WANT_GetMediaTrackInfo_Value +#define REAPERAPI_WANT_PreventUIRefresh +#include "reaper_plugin_functions.h" + +namespace reasampler { + +std::vector readFolderEntries(ReaProject* proj, + TrackHandles& handleByGuid) { + std::vector entries; + const int count = CountTracks(proj); + entries.reserve(static_cast(count)); + handleByGuid.reserve(static_cast(count)); + for (int i = 0; i < count; ++i) { + MediaTrack* tr = GetTrack(proj, i); + if (!tr) continue; + std::string guid = guidString(tr); + if (guid.empty()) continue; + const int depth = static_cast(GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH")); + entries.push_back(view::TrackFolderEntry{guid, depth}); + handleByGuid.emplace_back(std::move(guid), tr); + } + return entries; +} + +TrackByGuid indexHandles(const TrackHandles& handleByGuid) { + TrackByGuid byGuid; + byGuid.reserve(handleByGuid.size()); + for (const auto& kv : handleByGuid) byGuid.emplace(kv.first, kv.second); + return byGuid; +} + +UiRefreshHold::UiRefreshHold() { PreventUIRefresh(1); } +UiRefreshHold::~UiRefreshHold() { PreventUIRefresh(-1); } + +} // namespace reasampler diff --git a/src/shell/view/view_tracks.h b/src/shell/view/view_tracks.h new file mode 100644 index 0000000..f7109f2 --- /dev/null +++ b/src/shell/view/view_tracks.h @@ -0,0 +1,51 @@ +#pragma once +// The scaffolding every Design View write pass sets up: the live GUID-keyed track +// enumeration it plans against, and the UI-refresh hold its writes land under. +// The master track is outside GetTrack's index space, so nothing reached through +// these types can land on it. + +#include +#include +#include +#include + +#include "core/view/view_tree.h" + +// Forward-declared to keep this header SDK-free; the .cpp includes the real SDK header. +class MediaTrack; +class ReaProject; + +namespace reasampler { + +// (GUID, handle) in arrange order — the shape view_solo's one-pass writers take. +using TrackHandles = std::vector>; + +// The same handles indexed for lookup. Every park, restore, parent and lane op +// resolves a GUID; a linear scan made that O(T²) on the one path the user waits on. +using TrackByGuid = std::unordered_map; + +// Enumerates `proj` (nullptr = current project) into `handleByGuid` and returns each +// track's I_FOLDERDEPTH entry for buildFolderTree. A track REAPER reports no GUID +// for is skipped by both. +std::vector readFolderEntries(ReaProject* proj, + TrackHandles& handleByGuid); + +TrackByGuid indexHandles(const TrackHandles& handleByGuid); + +inline MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) { + auto it = byGuid.find(guid); + return it == byGuid.end() ? nullptr : it->second; // stale/deleted GUID — pruned by being skipped +} + +// Holds REAPER's UI refresh off for a write phase; the deliberate rebuild +// (TrackList_AdjustWindows + UpdateArrange) runs after it releases. RAII because an +// unbalanced pair leaves the user's UI frozen with no way back — the SDK's own +// warning at reaper_plugin_functions.h:5581. +struct UiRefreshHold { + UiRefreshHold(); + ~UiRefreshHold(); + UiRefreshHold(const UiRefreshHold&) = delete; + UiRefreshHold& operator=(const UiRefreshHold&) = delete; +}; + +} // namespace reasampler diff --git a/tests/test_applied_state.cpp b/tests/test_applied_state.cpp new file mode 100644 index 0000000..e36dceb --- /dev/null +++ b/tests/test_applied_state.cpp @@ -0,0 +1,152 @@ +// Standalone tests for reasampler::view::appliedState — no REAPER, no test +// framework. The fingerprint's whole job is discriminating "the model differs +// from what we applied" from "something happened"; the negative cases below are +// the load-bearing half. + +#include "../src/core/view/applied_state.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::view; + +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 { + +TrackSnapshot record(int tcp, int mixer, int mainSend, int fxEnable) { + TrackSnapshot snap; + snap.showInTcp = tcp; + snap.showInMixer = mixer; + snap.mainSend = mainSend; + snap.fxEnable = fxEnable; + snap.fxOffline = {{"{FX-A}", 0}}; + return snap; +} + +// Two leaves parked out of Design, one of which the user had OUT of the mix by +// hand (mainSend 0) — the value the sunk attempt re-enabled. +ViewModeModel parkedPair() { + ViewModeModel vm; + vm.membership().tag("{T1}", kDesignModeId); + vm.membership().tag("{T2}", kDesignModeId); + vm.storeSnapshot("{T1}", record(1, 1, 1, 1)); + vm.storeSnapshot("{T2}", record(1, 1, 0, 1)); + return vm; +} + +} // namespace + +static void testSameModelFingerprintsEqual() { + const AppliedState a = appliedState(parkedPair()); + const AppliedState b = appliedState(parkedPair()); + CHECK(a == b); + CHECK(!(a != b)); + + CHECK(a.activeModeId == kArrangeModeId); + CHECK(a.parked.size() == 2); + CHECK(a.parked.size() == 2 && a.parked[0].guid == "{T1}"); // GUID order + CHECK(a.parked.size() == 2 && a.parked[1].mainSend == 0); // captured, not driven +} + +static void testACapturedValueChangingIsADifference() { + // The reconcile case: a rolled-back view_state carries a different record for + // the same track, so what a restore would drive is no longer what we applied. + ViewModeModel vm = parkedPair(); + const AppliedState before = appliedState(vm); + vm.storeSnapshot("{T2}", record(1, 1, 1, 1)); // mainSend the user had at 0 + CHECK(appliedState(vm) != before); +} + +static void testTheParkedSetChangingIsADifference() { + ViewModeModel vm = parkedPair(); + const AppliedState before = appliedState(vm); + + vm.clearSnapshot("{T2}"); + CHECK(appliedState(vm) != before); + CHECK(appliedState(vm).parked.size() == 1); + + ViewModeModel grown = parkedPair(); + grown.storeSnapshot("{T3}", record(1, 1, 1, 1)); + CHECK(appliedState(grown) != before); +} + +static void testActiveModeChangingIsADifference() { + ViewModeModel vm = parkedPair(); + const AppliedState before = appliedState(vm); + CHECK(vm.setActiveMode(kDesignModeId)); + CHECK(appliedState(vm) != before); +} + +static void testASpentFlagHalfLeavesTheFingerprint() { + // A restored track is no longer one the tool holds away from the user's + // values; its zeroed record must not read as a parked track at all zeros. + ViewModeModel vm = parkedPair(); + vm.releaseSnapshotFlags("{T1}"); + + const AppliedState after = appliedState(vm); + CHECK(after.parked.size() == 1); + CHECK(after.parked.size() == 1 && after.parked[0].guid == "{T2}"); + + // The FX half outliving the flag half does NOT put it back. + CHECK(vm.holdsSnapshotFx("{T1}")); + CHECK(appliedState(vm) == after); +} + +static void testEditsTheApplyDidNotDriveAreNotDifferences() { + // The bug that sank the previous attempt was repairing on evidence of nothing. + // Each edit below changes the model without changing what the apply drove. + ViewModeModel vm = parkedPair(); + const AppliedState before = appliedState(vm); + + // {T1} is already parked (tagged Design, active mode Arrange); retagging it + // INTO the active mode changes what the next apply must drive (release it) + // while its park record — and so the fingerprint — is untouched. Pinning + // this on a GUID actually in the parked set is the point: an unrelated GUID + // would pass even if membership were folded into the fingerprint by mistake. + vm.membership().tag("{T1}", kArrangeModeId); + CHECK(appliedState(vm) == before); + + vm.membership().setShowBoth("{T1}", true); + CHECK(appliedState(vm) == before); + + vm.lanes().setManaged("{T1}", "reasampler:design", kDesignModeId); + CHECK(appliedState(vm) == before); + + vm.soloCache().store(kDesignModeId, {{"{T1}", 1}}); + CHECK(appliedState(vm) == before); + + // Per-FX offline is DERIVED — never persisted, never in an undo record — so a + // difference there is evidence of nothing about the model. + TrackSnapshot fxMoved = record(1, 1, 1, 1); + fxMoved.fxOffline = {{"{FX-A}", 1}, {"{FX-B}", 0}}; + vm.storeSnapshot("{T1}", fxMoved); + CHECK(appliedState(vm) == before); + + vm.releaseSnapshotFx("{T2}"); + CHECK(appliedState(vm) == before); +} + +static void testEmptyModelFingerprintsEqualAndNotParked() { + ViewModeModel a; + ViewModeModel b; + CHECK(appliedState(a) == appliedState(b)); + CHECK(appliedState(a).parked.empty()); + CHECK(appliedState(a) != appliedState(parkedPair())); +} + +int main() { + testSameModelFingerprintsEqual(); + testACapturedValueChangingIsADifference(); + testTheParkedSetChangingIsADifference(); + testActiveModeChangingIsADifference(); + testASpentFlagHalfLeavesTheFingerprint(); + testEditsTheApplyDidNotDriveAreNotDifferences(); + testEmptyModelFingerprintsEqualAndNotParked(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_fx_reclaim.cpp b/tests/test_fx_reclaim.cpp new file mode 100644 index 0000000..685078f --- /dev/null +++ b/tests/test_fx_reclaim.cpp @@ -0,0 +1,1652 @@ +// Standalone tests for the FX reclaimer's drain — no REAPER, no test framework. The +// loop is templated on its host precisely so the properties that make it safe to run in +// the background are assertable here: the debounce, the per-tick budget's between-track +// yield, the queue being a cache of intent that a model change discards, the user's own +// offline plugins surviving an online, the transport gate, the release reaching disk on +// 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. 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" + +#include +#include +#include +#include +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::view; + +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 { + +// -- a fake project ------------------------------------------------------------ + +// Two project handles. The queue and the flush treat a project pointer as COMPARE-ONLY +// and never dereference it, so a fabricated address is a faithful stand-in for REAPER's. +ReaProject* const kProjA = reinterpret_cast(static_cast(0x1)); +ReaProject* const kProjB = reinterpret_cast(static_cast(0x2)); + +// One live FX: its identity (empty when REAPER reports none) and whether it is offline. +struct FakeFx { + std::string guid; + bool offline = false; +}; + +// Stands in for the REAPER half of drainTick's Host contract. The clock advances only +// on a REAL write, mirroring the live host: a compare-before-write that finds the FX +// already where it wants it costs a read, not a plugin load. +struct FakeHost { + std::map> chains; + std::set released; + std::vector writeLog; + double clock = 0.0; + double stepCostMs = 50.0; + + // Models a re-entered timer landing inside TrackFX_SetOffline's message pump: on the + // Nth successful write, the write itself does to the queue what a nested applyMode + // used to do — clear it and mark it stale. + FxReclaimQueue* reentrant = nullptr; + 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 + // 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; } + + // 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(); + for (const FakeFx& fx : it->second) out.push_back(fx.guid); + return true; + } + + 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; + 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")); + + if (reentrant && static_cast(writeLog.size()) == invalidateAfterWrite) { + itemsSeenMidDrain = static_cast(reentrant->items().size()); + 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; + } + + // `model` is the live host's session model and `persisted` stands in for view_state + // on disk — the blob a reopen or an undo/redo loadFromProject rebuilds from. The + // drain writes the model and NEVER `persisted`: only saveProject() below does, which + // is what makes "the spend rides the user's save" assertable. + ViewModeModel* model = nullptr; + ReleaseFlush* flush = nullptr; + ReaProject* project = kProjA; + std::string persisted; + + void releaseFx(const std::string& guid) { + released.insert(guid); + if (model) model->releaseSnapshotFx(guid); + if (flush) flush->arm(project); + } + + bool offlineAt(const std::string& guid, std::size_t slot) const { + auto it = chains.find(guid); + return it != chains.end() && slot < it->second.size() && it->second[slot].offline; + } +}; + +// What the project-save hook does (fxReclaimFlushRelease): write the in-memory model into +// this project's view state, but only when it is the project holding the release. Does +// NOT clear the arm — see ReleaseFlush — so calling this twice re-issues the same write. +void saveProject(FakeHost& host, ReleaseFlush& flush, ReaProject* active) { + if (!flush.pendingFor(active)) return; + host.persisted = host.model->serialize(); +} + +// -- model helpers ------------------------------------------------------------- + +TrackSnapshot recordWithFx(std::vector fx) { + TrackSnapshot snap; + snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1; + snap.fxOffline = std::move(fx); + return snap; +} + +// What the shell does on every invalidation: throw the queue away and recompute it. +std::vector derive(const ViewModeModel& vm, const std::set& live) { + return makeQueue(desiredFxState(vm, vm.activeModeId(), live)); +} + +std::vector onlineChain(const std::vector& guids) { + std::vector chain; + for (const std::string& g : guids) chain.push_back(FakeFx{g, false}); + return chain; +} + +std::vector offlineChain(const std::vector& guids) { + std::vector chain; + for (const std::string& g : guids) chain.push_back(FakeFx{g, true}); + return chain; +} + +// Design leaves the tool has parked: record held, both halves, nothing user-offlined. +// Active mode stays Arrange, so every one of them is parked. +ViewModeModel parkedDesignLeaves(const std::map>& tracks) { + ViewModeModel vm; + for (const auto& [guid, fxGuids] : tracks) { + vm.membership().tag(guid, kDesignModeId); + std::vector fx; + for (const std::string& g : fxGuids) fx.push_back(FxOfflineState{g, 0}); + vm.storeSnapshot(guid, recordWithFx(fx)); + } + return vm; +} + +ViewModeModel parkedDesignLeaf(const std::vector& fxGuids) { + return parkedDesignLeaves({{"{T}", fxGuids}}); +} + +// Puts the queue's offline items past their debounce the way the live loop does: the +// hold starts at the FIRST observation, so advancing the clock before that one just +// moves the origin and matures nothing. +void matureHold(std::vector& items, ParkClock& clock, FakeHost& host) { + clock.observe(items, host.nowMs()); + host.clock += kParkHoldMs; +} + +// Runs ticks until the queue settles, bounded so a non-terminating drain fails the test +// rather than hanging CTest. The two overloads are the two entry points: the pure drain +// over the caller's own storage, and the owned queue the shell actually runs. +int drainToSettled(std::vector& items, ParkClock& clock, FakeHost& host, + int maxTicks = 64) { + for (int t = 1; t <= maxTicks; ++t) + if (drainTick(items, clock, host).settled) return t; + CHECK(false); + return -1; +} + +int drainToSettled(FxReclaimQueue& queue, FakeHost& host, int maxTicks = 64) { + for (int t = 1; t <= maxTicks; ++t) + if (queue.run(host).settled) return t; + CHECK(false); // did not settle — the drain is not making forward progress + return -1; +} + +FxReclaimQueue queueOf(const ViewModeModel& vm, const std::set& live) { + FxReclaimQueue q; + q.adopt(derive(vm, live)); + 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 --------------------------------------------------------------- + +static void testFastFlipMovesZeroPluginsButAHeldParkStillReclaims() { + // A→B: {T} parks. The reclaimer must not touch it yet. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Offline); + CHECK(q.run(host).writes == 0); + CHECK(!q.run(host).settled); // waiting out its hold, not finished + + // B→A half a second later: the flags come back and the FX half is still held, so the + // reclaimer is now owed an ONLINE — over a chain it never got round to offlining. + host.clock = 500.0; + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + q.adopt(derive(vm, {"{T}"})); + CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Online); + drainToSettled(q, host); + + // The whole point of the 3s hold: a fast A→B→A flip moved NO plugins. + CHECK(host.writeLog.empty()); + CHECK(!host.offlineAt("{T}", 0) && !host.offlineAt("{T}", 1) && !host.offlineAt("{T}", 2)); + CHECK(host.released.count("{T}") == 1); // the chain is verifiably online; record spent + + // The contrast, so the assertion above is about the hold and not about the drain + // being inert: the same park held past 3s DOES reclaim. + ViewModeModel held = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); + FakeHost host2; + host2.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); + FxReclaimQueue q2 = queueOf(held, {"{T}"}); + CHECK(q2.run(host2).writes == 0); // observed parked at t=0 + host2.clock = kParkHoldMs; + drainToSettled(q2, host2); + CHECK(host2.writeLog.size() == 3); + CHECK(host2.offlineAt("{T}", 0) && host2.offlineAt("{T}", 1) && host2.offlineAt("{T}", 2)); + CHECK(host2.released.empty()); // an OFFLINE never releases — the record is still owed +} + +// -- 2. budget: the step is a whole track ---------------------------------------- + +static void testAWholeChainMovesInOneStepAndTheYieldFallsBetweenTracks() { + // {T1}'s chain costs 4x the offline budget on its own. The budget cannot split it — + // a step is one track — so the tick overruns to finish it and yields only once the + // NEXT track would start. Slot 1 carries no identity, which the drain no longer has + // to reason about at all now that nothing resumes mid-chain. + ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "", "{FX-C}", "{FX-D}"}}, + {"{T2}", {"{FX-E}"}}, + {"{T3}", {"{FX-F}"}}}); + FakeHost host; + host.chains["{T1}"] = onlineChain({"{FX-A}", "", "{FX-C}", "{FX-D}"}); + host.chains["{T2}"] = onlineChain({"{FX-E}"}); + host.chains["{T3}"] = onlineChain({"{FX-F}"}); + host.stepCostMs = 50.0; // dearer than kOfflineBudgetMs ⇒ one TRACK per tick + ParkClock clock; + + std::vector items = derive(vm, {"{T1}", "{T2}", "{T3}"}); + CHECK(items.size() == 3); + matureHold(items, clock, host); + + CHECK(drainTick(items, clock, host).writes == 4); // all of {T1}, 200ms over a 30ms budget + CHECK(items[0].done && !items[1].done && !items[2].done); + const std::vector firstTick{"{T1}:0=off", "{T1}:1=off", "{T1}:2=off", + "{T1}:3=off"}; + CHECK(host.writeLog == firstTick); + + // {T2} then {T3}, one track per tick; the tick that finishes the last one exhausts + // the queue in the same pass rather than costing a spare tick. + CHECK(drainToSettled(items, clock, host) == 2); + std::vector expected = firstTick; + expected.push_back("{T2}:0=off"); + expected.push_back("{T3}:0=off"); + CHECK(host.writeLog == expected); +} + +static void testOnlineDrainGetsTheLargerBudget() { + // Same per-plugin cost, same track count, one plugin each so neither direction is + // distorted by an overrunning chain: the only difference is the direction, and + // onlining is the one the user is waiting on. + const std::map> tracks{ + {"{T1}", {"{FX-1}"}}, {"{T2}", {"{FX-2}"}}, {"{T3}", {"{FX-3}"}}, {"{T4}", {"{FX-4}"}}}; + + ViewModeModel back = parkedDesignLeaves(tracks); + back.setActiveMode(kDesignModeId); + FakeHost onlineHost; + for (const auto& [guid, fx] : tracks) { + back.releaseSnapshotFlags(guid); + onlineHost.chains[guid] = offlineChain(fx); + } + onlineHost.stepCostMs = 50.0; + ParkClock onlineClock; + + std::vector onlineItems = derive(back, {"{T1}", "{T2}", "{T3}", "{T4}"}); + CHECK(onlineItems.size() == 4 && onlineItems[0].work.kind == FxWorkKind::Online); + CHECK(drainTick(onlineItems, onlineClock, onlineHost).writes == 3); // 120ms buys three + + ViewModeModel parked = parkedDesignLeaves(tracks); + FakeHost offlineHost; + for (const auto& [guid, fx] : tracks) offlineHost.chains[guid] = onlineChain(fx); + offlineHost.stepCostMs = 50.0; + ParkClock offlineClock; + + std::vector offlineItems = derive(parked, {"{T1}", "{T2}", "{T3}", "{T4}"}); + matureHold(offlineItems, offlineClock, offlineHost); + CHECK(drainTick(offlineItems, offlineClock, offlineHost).writes == 1); // 30ms buys one +} + +static void testATickStartedOnOnlineWorkDoesNotSpendIntoOfflineWork() { + 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; // cheap enough that only the kind break can stop the tick + ParkClock clock; + + std::vector items = derive(vm, {"{Z-back}", "{A-park}"}); + CHECK(items.size() == 2); + CHECK(items[0].work.kind == FxWorkKind::Online); // fx_state's ordering + matureHold(items, clock, host); // the parked track's hold is no longer the blocker + + CHECK(drainTick(items, clock, host).writes == 1); + CHECK(host.writeLog == std::vector{"{Z-back}:0=on"}); + CHECK(!host.offlineAt("{A-park}", 0)); // opportunistic reclaim waits for its own tick + + CHECK(drainTick(items, clock, host).writes == 1); + CHECK(host.offlineAt("{A-park}", 0)); +} + +// -- 3. invalidation: the queue is a cache of intent ---------------------------- + +static void testModelChangeDiscardsDrainProgressButNotTheParkHold() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}", "{FX-C}"}); + vm.membership().tag("{U}", kDesignModeId); // a second parked leaf, left alone below + vm.storeSnapshot("{U}", recordWithFx({{"{FX-U}", 0}})); + + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); + host.chains["{U}"] = onlineChain({"{FX-U}"}); + host.stepCostMs = 50.0; + ParkClock clock; + + std::vector items = derive(vm, {"{T}", "{U}"}); + drainTick(items, clock, host); // observes both parked at t=0 + host.clock = kParkHoldMs + 100.0; + drainTick(items, clock, host); // {T}'s whole chain moves, then the tick yields + CHECK(items[0].done && !items[1].done); + + // The model changes: {T} is retagged into the active mode and restored, while {U} + // stays exactly as parked as it was. The queue is discarded whole — direction, + // completion marks and all — and re-derived. + vm.membership().tag("{T}", kArrangeModeId); + vm.releaseSnapshotFlags("{T}"); + items = derive(vm, {"{T}", "{U}"}); + CHECK(items.size() == 2); + CHECK(items[0].work.kind == FxWorkKind::Online); + CHECK(items[0].work.trackGuid == "{T}"); + CHECK(!items[0].done && !items[1].done); // no drain progress survives a re-derive + + // ...but {U}'s hold does survive it. {U} never stopped being parked, and restarting + // its 3s on every unrelated model change would defer its reclaim indefinitely. + clock.observe(items, host.clock); + CHECK(clock.matured("{U}", host.clock)); + CHECK(nextEligible(items, clock, host.clock) == 0); // online first, then {U} available + + // The mirror: a track that LEAVES the offline set and comes back starts a fresh hold. + // {T} was dropped from the clock by the observation above, while it was online. + vm.membership().tag("{T}", kDesignModeId); + items = derive(vm, {"{T}", "{U}"}); + clock.observe(items, host.clock); + CHECK(!clock.matured("{T}", host.clock)); + CHECK(!clock.matured("{T}", host.clock + kParkHoldMs - 1.0)); + CHECK(clock.matured("{T}", host.clock + kParkHoldMs)); + CHECK(clock.matured("{U}", host.clock)); // and {U}'s own hold still is not restarted +} + +// -- 4. the user's own offline plugins ------------------------------------------ + +static void testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain() { + // {FX-B} was offline before the park because the USER put it there; the record is + // the only account of that, and an online must honor it. + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); + vm.setActiveMode(kDesignModeId); + vm.storeSnapshot("{T}", recordWithFx({{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}})); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}", "{FX-C}"}); // fully parked + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + drainToSettled(q, host); + + CHECK(!host.offlineAt("{T}", 0)); + CHECK(host.offlineAt("{T}", 1)); // still offline — never blanket-onlined + CHECK(!host.offlineAt("{T}", 2)); + for (const std::string& entry : host.writeLog) CHECK(entry != "{T}:1=on"); + CHECK(host.writeLog.size() == 2); + CHECK(host.released.count("{T}") == 1); +} + +// -- forward progress ----------------------------------------------------------- + +static void testSlotKeyedOnlineRecordRestoresByPosition() { + // A record lifted from a project saved before FX identity existed: its targets hold + // no identity at all, so position is the only thing that can address them. + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); + vm.setActiveMode(kDesignModeId); + TrackSnapshot snap = recordWithFx({{"", 0}, {"", 1}, {"", 0}}); + snap.fxKeying = FxKeying::Slot; + vm.storeSnapshot("{T}", snap); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.chains["{T}"] = offlineChain({"{FX-X}", "{FX-Y}", "{FX-Z}"}); + // Dearer per plugin than the whole online budget: the step still runs the chain to + // the end, because the budget cannot split one track's move. + host.stepCostMs = kOnlineBudgetMs + 10.0; + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(q.items().size() == 1); + CHECK(drainToSettled(q, host) == 1); + const std::vector expected{"{T}:0=on", "{T}:2=on"}; + CHECK(host.writeLog == expected); // slot 1 stays offline, as the record captured + CHECK(host.offlineAt("{T}", 1)); +} + +static void testATrackThatLeftTheProjectCompletesRatherThanStalls() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; // no chain registered ⇒ liveChain reports the track gone + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(!q.run(host).settled); // observes the hold at t=0; not matured yet + host.clock += kParkHoldMs; + CHECK(q.run(host).settled); + CHECK(host.writeLog.empty()); + CHECK(host.released.empty()); // the next apply's reconcile drops the whole record +} + +// -- 5. re-entrancy: an invalidation arriving with a drain on the stack ------------ + +static void testAReentrantInvalidationReachesNothingTheDrainIsUsing() { + // The second of {T1}'s four writes re-enters and does what a nested applyMode used to + // do to the shared queue: clear it. The drain is mid-item at that moment. + ViewModeModel vm = parkedDesignLeaves({{"{T1}", {"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"}}, + {"{T2}", {"{FX-E}"}}}); + FakeHost host; + host.chains["{T1}"] = onlineChain({"{FX-A}", "{FX-B}", "{FX-C}", "{FX-D}"}); + host.chains["{T2}"] = onlineChain({"{FX-E}"}); + host.stepCostMs = 50.0; + + FxReclaimQueue q = queueOf(vm, {"{T1}", "{T2}"}); + q.run(host); // observes both parked at t=0 + host.clock += kParkHoldMs; + + host.reentrant = &q; + host.invalidateAfterWrite = 2; + const ReclaimRunResult r = q.run(host); + + // The ownership claim: for the whole length of the drain the queue object holds + // nothing, so there is no element for a re-entrant caller to destroy. + CHECK(host.itemsSeenMidDrain == 0); + + // The drain finished the track it was on out of its own copy — a step is atomic. + const std::vector whole{"{T1}:0=off", "{T1}:1=off", "{T1}:2=off", + "{T1}:3=off"}; + CHECK(host.writeLog == whole); + CHECK(r.writes == 4); + + // ...and then refused to commit work derived from a model that has since moved. + CHECK(r.aborted); + CHECK(!r.settled); + CHECK(q.stale()); + CHECK(q.items().empty()); +} + +static void testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled() { + // One item, so the drain would otherwise report settled and the shell would go idle — + // clearing the pending flag the re-entrant invalidation had just raised and stranding + // its work until the next mode switch. + 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 r = q.run(host); + CHECK(r.writes == 1); + CHECK(!r.settled); // the one bit that decides whether the shell clears its wakeup + CHECK(r.aborted); + CHECK(q.stale()); // and the next tick re-derives rather than finding an empty queue + + // The contrast, so the assertion above is about the invalidation and not about this + // queue being unable to settle: the same drain settles once nothing re-enters it. + host.reentrant = nullptr; + q.adopt(derive(vm, {"{T}"})); + CHECK(q.run(host).settled); +} + +static void testAnInvalidationDuringAnOnlineDropsThatTicksRelease() { + // Releasing spends the record's FX half — the only account of which plugins the USER + // had offline before the park. An invalidation mid-drain is the signal that the model + // underneath may have been replaced (a project load replaces it in place), so the + // release must not land on it. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + ReleaseFlush flush; + host.flush = &flush; + host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Online); + host.reentrant = &q; + host.invalidateAfterWrite = 1; + + const ReclaimRunResult r = q.run(host); + CHECK(r.aborted); + CHECK(r.writes == 2); // the item still completed — a step is atomic + CHECK(host.released.empty()); // but its record was NOT spent + CHECK(!flush.pendingFor(kProjA)); // and no save is owed one that never happened + + // Re-owed, not lost: the re-derived item finds the chain already where the record + // wants it, writes nothing more, and releases then. + host.reentrant = nullptr; + q.adopt(derive(vm, {"{T}"})); + CHECK(q.run(host).settled); + CHECK(host.writeLog.size() == 2); + CHECK(host.released.count("{T}") == 1); + CHECK(flush.pendingFor(kProjA)); +} + +// -- 6. the release has to outlive the session ------------------------------------ + +static void testACompletedOnlineReachesDiskOnTheSaveAndNotOnTheDrain() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + + // The bug the flush closes, stated as the baseline: the model as it stands BEFORE the + // release re-derives the same Online work when reloaded, and re-driving it would + // revert whatever per-FX offline the user set after the reclaim had finished. + FakeHost host; + host.model = &vm; + host.persisted = vm.serialize(); // the project as it sits on disk, record unspent + host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); + ReleaseFlush flush; + host.flush = &flush; + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + drainToSettled(q, host); + CHECK(host.released.count("{T}") == 1); + + // The drain wrote NOTHING durable. That is the whole change: a background tick that + // persisted would also have dirtied the project, asking the user to save one they + // had only opened. + const std::optional beforeSave = ViewModeModel::deserialize(host.persisted); + CHECK(beforeSave.has_value()); + CHECK(beforeSave->snapshot("{T}") != nullptr); + CHECK(derive(*beforeSave, {"{T}"}).size() == 1); // still stale on disk + CHECK(flush.pendingFor(kProjA)); // ...and the save knows it is owed + + // The user's next save is what spends it — and afterwards a reload plans no work, + // so nothing re-drives the chain. The arm itself survives the write (see + // testTheFlushArmSurvivesASaveAndCanBeReIssued) — this test is about the disk content. + saveProject(host, flush, kProjA); + CHECK(flush.pendingFor(kProjA)); + const std::optional afterSave = ViewModeModel::deserialize(host.persisted); + CHECK(afterSave.has_value()); + CHECK(afterSave->snapshot("{T}") == nullptr); // both halves spent ⇒ record erased + CHECK(derive(*afterSave, {"{T}"}).empty()); +} + +static void testAReleaseKilledBeforeTheFlushIsSimplyReEarned() { + // The exposure the save-time flush opens: between the in-memory release and the next + // save, the record on disk is still unspent. Quitting there must cost a redo of the + // reclaim, never a repair. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.model = &vm; + host.persisted = vm.serialize(); + host.chains["{T}"] = offlineChain({"{FX-A}", "{FX-B}"}); + ReleaseFlush flush; + host.flush = &flush; + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + drainToSettled(q, host); + CHECK(host.writeLog.size() == 2); // the chain really was driven online + CHECK(flush.pendingFor(kProjA)); + + // Killed here: no save, so the model in memory goes and the disk record survives. + const std::optional reopened = ViewModeModel::deserialize(host.persisted); + CHECK(reopened.has_value()); + ViewModeModel reloaded = *reopened; + + FakeHost after; + after.chains = host.chains; // the DAW state outlives the reload: the chain is online + after.model = &reloaded; + ReleaseFlush flushAfter; + after.flush = &flushAfter; + + FxReclaimQueue q2 = queueOf(reloaded, {"{T}"}); + CHECK(q2.items().size() == 1); // the surviving record re-derives the same Online work + CHECK(drainToSettled(q2, after) == 1); + CHECK(after.writeLog.empty()); // compare-before-write: nothing left to move + CHECK(after.released.count("{T}") == 1); + CHECK(flushAfter.pendingFor(kProjA)); // and the spend is owed to the next save again +} + +static void testATickThatSpendsNoRecordOwesNoSaveAnything() { + // OFFLINE work spends no record, so there is nothing to make durable — and the + // reclaimer must never give the user's next save work it did not earn. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + host.model = &vm; + host.chains["{T}"] = onlineChain({"{FX-A}"}); + ReleaseFlush flush; + host.flush = &flush; + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + q.run(host); // observes the hold at t=0 + host.clock += kParkHoldMs; + drainToSettled(q, host); + + CHECK(host.writeLog.size() == 1); // it did move the plugin + CHECK(host.released.empty()); + CHECK(!flush.pendingFor(kProjA)); + saveProject(host, flush, kProjA); + CHECK(host.persisted.empty()); +} + +static void testTheFlushArmSurvivesASaveAndCanBeReIssued() { + // Whether a write made from inside the save hook reaches the very file being written + // now, or lands one save late, is unsettled (ReleaseFlush's [verify — DAW]). The arm + // must not treat one issued write as proof of a durable spend: it stays armed, so an + // unfavourable answer still gets a retry on the project's NEXT save rather than + // stranding the record in memory for the rest of the session. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.model = &vm; + ReleaseFlush flush; + flush.arm(kProjA); + + saveProject(host, flush, kProjA); + CHECK(!host.persisted.empty()); + CHECK(flush.pendingFor(kProjA)); // survives the flush — not disarmed by one write + + // A second save re-issues the identical write rather than finding nothing to do. + host.persisted.clear(); + saveProject(host, flush, kProjA); + CHECK(!host.persisted.empty()); + CHECK(flush.pendingFor(kProjA)); + + // A different project's save must still leave this arm untouched. + saveProject(host, flush, kProjB); + CHECK(flush.pendingFor(kProjA)); +} + +// -- 7. captured FX state the online direction cannot place ------------------------- + +static void testADropIsReportedOnceAcrossRepeatedRederivations() { + // {FX-B} was deleted while the track was parked, so its captured state has nowhere + // to land. Swallowing that leaves the user no trace of it at all. + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); + vm.setActiveMode(kDesignModeId); + vm.storeSnapshot("{T}", recordWithFx({{"{FX-A}", 0}, {"{FX-B}", 1}})); + vm.releaseSnapshotFlags("{T}"); + + FakeHost host; + host.model = &vm; + host.chains["{T}"] = offlineChain({"{FX-A}"}); // {FX-B} gone from the chain + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + host.reentrant = &q; + host.invalidateAfterWrite = 1; // aborts, so the identical item re-derives next tick + + const ReclaimRunResult first = q.run(host); + CHECK(first.aborted); + CHECK(first.drops.size() == 1); + CHECK(first.drops[0].trackGuid == "{T}"); + CHECK(first.drops[0].drops.missingIdentity == 1); + CHECK(first.drops[0].drops.total() == 1); + CHECK(host.released.empty()); // the abort still refuses to spend the record + + host.reentrant = nullptr; + q.adopt(derive(vm, {"{T}"})); + const ReclaimRunResult second = q.run(host); + CHECK(second.settled); + CHECK(second.drops == first.drops); // the re-derivation resolves the very same set + + // The gate the shell's console report sits behind: first sighting prints, that + // identical re-derivation does not, a different set does. + const std::vector none; + CHECK(shouldReportDrops(/*sameOwnerAsLast=*/false, none, first.drops)); + CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/true, first.drops, second.drops)); + const std::vector elsewhere{TrackFxDrops{"{U}", FxRestoreDrops{1, 0, 0}}}; + CHECK(shouldReportDrops(/*sameOwnerAsLast=*/true, first.drops, elsewhere)); + + // The record IS spent with a drop outstanding: holding it for an FX the chain no + // longer has would replan work that can never resolve. + CHECK(host.released.count("{T}") == 1); + CHECK(!host.offlineAt("{T}", 0)); // and the placeable half did land +} + +static void testAnOfflineTickReportsNoDrops() { + // Offline targets are the live chain's own slots, so nothing can fail to resolve. + // Keeps the drop assertions above honest: they must catch a real degrade, not an + // accumulator that fills on every tick. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + q.run(host); + host.clock += kParkHoldMs; + const ReclaimRunResult r = q.run(host); + CHECK(r.settled); + CHECK(r.writes == 2); + CHECK(r.drops.empty()); +} + +// -- 8. the pin and the wakeup ------------------------------------------------------ + +static void testATabSwitchRetargetsTheQueueAndLeavesTheWakeupStanding() { + // Project A, one parked leaf owed offline work, derived against A and pinned to it. + ViewModeModel a = parkedDesignLeaf({"{FX-A}"}); + FakeHost hostA; + hostA.chains["{T}"] = onlineChain({"{FX-A}"}); + + FxReclaimQueue q; + q.invalidate(); // what applyMode does on its way out of the switch that parked {T} + CHECK(q.pending()); + CHECK(q.beginTick(kProjA)); + CHECK(q.project() == kProjA); + q.adopt(derive(a, {"{T}"})); + CHECK(!q.run(hostA).settled); // observes the hold at t=0; still owed + CHECK(!q.stale()); + + // The tab switch. poll()'s Load reapply invalidates on the way in, and NOTHING there + // moves the pin, so the tick arrives holding A's work with B active. + q.invalidate(); + CHECK(q.beginTick(kProjB)); // the tick proceeds — it does not bail on the mismatch + CHECK(q.pending()); // and the timer will still call it: the strand's one bit + CHECK(q.project() == kProjB); + CHECK(q.stale()); // A's work is gone, B's is owed + CHECK(q.items().empty()); + + // B's own parked leaf converges on this very tick and every one after it. + ViewModeModel b = parkedDesignLeaves({{"{T2}", {"{FX-B}"}}}); + FakeHost hostB; + hostB.chains["{T2}"] = onlineChain({"{FX-B}"}); + q.adopt(derive(b, {"{T2}"})); + CHECK(!q.run(hostB).settled); // B's own hold starts here + hostB.clock += kParkHoldMs; + const ReclaimRunResult done = q.run(hostB); + CHECK(done.settled); + CHECK(hostB.writeLog == std::vector{"{T2}:0=off"}); + CHECK(hostB.offlineAt("{T2}", 0)); + + // ...and A's chain was never touched from B: the queue was dropped, not run. + CHECK(hostA.writeLog.empty()); + + // Only now, settled, does the shell stop the ticks. + q.goIdle(); + CHECK(!q.pending()); + CHECK(q.project() == nullptr); +} + +static void testGoingIdleKeepsTheParkHoldWhileARetargetDropsIt() { + // A hold is evidence about a track in ONE project, so a tab switch drops it. An + // ordinary settle does not: restarting every 3s hold on the next unrelated membership + // edit would defer an unchanged track's reclaim indefinitely. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}"}); + + FxReclaimQueue q; + CHECK(q.beginTick(kProjA)); + q.adopt(derive(vm, {"{T}"})); + q.run(host); // hold starts at t=0 + host.clock = kParkHoldMs; + CHECK(q.run(host).settled); + CHECK(host.writeLog.size() == 1); + + // Settled: the queue and the pin go, the hold stays. A membership edit elsewhere + // re-derives the SAME offline item, which is matured on arrival — one tick, no + // second 3s wait, and nothing to write because the chain is already there. + q.goIdle(); + q.invalidate(); + CHECK(q.beginTick(kProjA)); + q.adopt(derive(vm, {"{T}"})); + const ReclaimRunResult resumed = q.run(host); + CHECK(resumed.settled); + CHECK(resumed.writes == 0); + CHECK(host.writeLog.size() == 1); + + // The mirror: away to B and back to A, and the same item must wait a fresh hold. + q.invalidate(); + CHECK(q.beginTick(kProjB)); + CHECK(q.beginTick(kProjA)); + q.adopt(derive(vm, {"{T}"})); + const double back = host.clock; + CHECK(!q.run(host).settled); // the hold restarts here, at `back` + host.clock = back + kParkHoldMs - 1.0; + CHECK(!q.run(host).settled); + host.clock = back + kParkHoldMs; + CHECK(q.run(host).settled); +} + +static void testOnlyANoProjectTickClearsTheWakeup() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}"}); + + FxReclaimQueue q; + q.invalidate(); + CHECK(q.beginTick(kProjA)); + q.adopt(derive(vm, {"{T}"})); + q.run(host); + host.clock += kParkHoldMs; + + // An abort leaves the wakeup up: the work is re-owed, and the shell's settle test + // never runs on this path. + host.reentrant = &q; + host.invalidateAfterWrite = 1; + const ReclaimRunResult r = q.run(host); + CHECK(r.aborted); + CHECK(q.pending()); + + // No active project at all — the one arm with nothing to converge and nothing to + // come back to. Everything goes, wakeup included. + CHECK(!q.beginTick(nullptr)); + CHECK(!q.pending()); + CHECK(q.project() == nullptr); + CHECK(q.stale()); + CHECK(q.items().empty()); +} + +static void testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo() { + const std::vector none; + const std::vector lost{TrackFxDrops{"{T}", FxRestoreDrops{1, 0, 0}}}; + + CHECK(shouldReportDrops(/*sameOwnerAsLast=*/false, none, lost)); // first sighting + CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/true, lost, lost)); // the re-derivation + + // The regression: every ordinary tick carries no drops at all. Treating that as a + // change would overwrite the memo and re-arm the identical report for the next + // re-derivation — the shell holds the memo back on exactly this answer. + CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/true, lost, none)); + CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/false, lost, none)); + CHECK(!shouldReportDrops(/*sameOwnerAsLast=*/true, lost, lost)); +} + +// -- 9. the transport gate ------------------------------------------------------------ + +static void testARollingTransportDefersOfflineWorkAndKeepsTheHold() { + ViewModeModel vm = parkedDesignLeaf({"{FX-A}", "{FX-B}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}", "{FX-B}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(!q.run(host).settled); // the hold starts at t=0 + + host.rolling = true; + host.clock += kParkHoldMs; // matured — the debounce is no longer what holds it back + const ReclaimRunResult gated = q.run(host); + CHECK(gated.writes == 0); + CHECK(host.writeLog.empty()); + CHECK(!gated.aborted); + CHECK(!gated.settled); // the work is still owed — a gated item is deferred, not dropped + CHECK(!q.stale()); // and the queue it will resume from survives untouched + + // The hold kept its origin across the gated tick: the drain fires on the FIRST tick + // after the transport stops, with no fresh 3s wait. + host.rolling = false; + CHECK(q.run(host).settled); + CHECK(host.writeLog.size() == 2); +} + +static void testOnlineWorkProceedsWhileTheTransportRolls() { + // Daniel's ruling: the gate narrows to the offline direction. Onlining is the mode + // switch's own unfinished promise — restoreTrack already handed back visibility and + // B_MAINSEND/I_FXEN synchronously — so a full gate would leave the user listening to a + // dry, FX-enabled chain for the whole rest of the playback pass. Onlining still costs + // a plugin load mid-roll; that is the accepted trade, not a regression this test flags. + 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; + host.rolling = true; + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + CHECK(q.items().size() == 1 && q.items()[0].work.kind == FxWorkKind::Online); + const ReclaimRunResult run = q.run(host); + CHECK(run.writes == 2); + CHECK(run.settled); + CHECK(host.writeLog.size() == 2); + CHECK(host.released.count("{T}") == 1); // the record's FX half is spent + CHECK(flush.pendingFor(kProjA)); + CHECK(!host.offlineAt("{T}", 0) && !host.offlineAt("{T}", 1)); +} + +static void testOfflineWorkStaysGatedWhileOnlineDrainsInTheSameTick() { + // A mixed queue while rolling: {Z-back} is owed an online, {A-park}'s hold is already + // matured and owed an offline. Only the online direction may move this tick. + 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}"}); + ParkClock clock; + + std::vector items = derive(vm, {"{Z-back}", "{A-park}"}); + CHECK(items[0].work.kind == FxWorkKind::Online); + matureHold(items, clock, host); // {A-park}'s hold matures with time to spare below + + host.rolling = true; + host.clock += kParkHoldMs; + const ReclaimTickResult tick = drainTick(items, clock, host); + CHECK(tick.writes == 1); + CHECK(host.writeLog == std::vector{"{Z-back}:0=on"}); + CHECK(!host.offlineAt("{A-park}", 0)); // matured but gated — untouched this tick + CHECK(!tick.settled); // the offline item is still owed + + // The transport stops: the very next tick reclaims it, with no fresh hold to wait out. + host.rolling = false; + CHECK(drainTick(items, clock, host).settled); + CHECK(host.offlineAt("{A-park}", 0)); +} + +static void testAHoldDropsWhenItsTrackStopsOwingOfflineWorkWhileRolling() { + // ParkClock's own contract (fx_reclaim.h) says an entry is dropped the moment its + // track stops owing offline work — observe() has to run every tick, rolling or not, + // for that to hold once a rolling tick can itself change what a track owes (by + // draining its online item). This is the case that contract would otherwise miss. + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + // Offline in the DAW, matching a track truly parked — so bringing it online below is a + // real write, not a compare-before-write no-op. + host.chains["{T}"] = offlineChain({"{FX-A}"}); + ParkClock clock; + + std::vector items = derive(vm, {"{T}"}); + CHECK(drainTick(items, clock, host).writes == 0); // the hold starts at t=0 + CHECK(!clock.matured("{T}", 0.0)); + + // {T} comes back online while the transport rolls; the online direction still drains. + host.rolling = true; + vm.setActiveMode(kDesignModeId); + vm.releaseSnapshotFlags("{T}"); + items = derive(vm, {"{T}"}); + CHECK(items[0].work.kind == FxWorkKind::Online); + host.clock = 100.0; + CHECK(drainTick(items, clock, host).writes == 1); + CHECK(!clock.matured("{T}", host.clock)); // dropped: {T} owes no offline work now + + // {T} parks again, still rolling: the hold must start FRESH from here, not resume the + // one interrupted above — proof the drop above was real, not a no-op. + vm.setActiveMode(kArrangeModeId); + vm.storeSnapshot("{T}", recordWithFx({{"{FX-A}", 0}})); + items = derive(vm, {"{T}"}); + CHECK(items[0].work.kind == FxWorkKind::Offline); + const double repark = host.clock; + CHECK(!drainTick(items, clock, host).settled); // gated, and not matured either + CHECK(!clock.matured("{T}", repark + kParkHoldMs - 1.0)); + CHECK(clock.matured("{T}", repark + kParkHoldMs)); + + host.rolling = false; + host.clock = repark + kParkHoldMs; + CHECK(drainTick(items, clock, host).writes == 1); // matures once the gate lifts +} + +static void testAGatedTickStillGoesIdleWithNothingLeftToConverge() { + // A settled queue must not spin the timer for the length of a playback pass: the + // gate's answer is "no writes", not "never finished". + ViewModeModel vm = parkedDesignLeaf({"{FX-A}"}); + FakeHost host; + host.chains["{T}"] = onlineChain({"{FX-A}"}); + + FxReclaimQueue q = queueOf(vm, {"{T}"}); + q.run(host); + host.clock += kParkHoldMs; + CHECK(q.run(host).settled); + + host.rolling = true; + 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()); +} + +// -- 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 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()); + + // ...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 + // 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() { + // 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 +} + +// -- 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. 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; + 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. 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(); + q.beginTick(kProjA); + span.begin(kProjA, "ReaSampler: activate Design view", 9); + + if (span.project() != arrived) span.end(); + q.beginTick(arrived); + + 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, 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() { + testFastFlipMovesZeroPluginsButAHeldParkStillReclaims(); + + testAWholeChainMovesInOneStepAndTheYieldFallsBetweenTracks(); + testOnlineDrainGetsTheLargerBudget(); + testATickStartedOnOnlineWorkDoesNotSpendIntoOfflineWork(); + + testModelChangeDiscardsDrainProgressButNotTheParkHold(); + + testAPluginTheUserOfflinedIsNeverOnlinedByTheDrain(); + + testSlotKeyedOnlineRecordRestoresByPosition(); + testATrackThatLeftTheProjectCompletesRatherThanStalls(); + + testAReentrantInvalidationReachesNothingTheDrainIsUsing(); + testAnInvalidationDuringTheLastItemLeavesTheTickUnsettled(); + testAnInvalidationDuringAnOnlineDropsThatTicksRelease(); + + testACompletedOnlineReachesDiskOnTheSaveAndNotOnTheDrain(); + testAReleaseKilledBeforeTheFlushIsSimplyReEarned(); + testATickThatSpendsNoRecordOwesNoSaveAnything(); + testTheFlushArmSurvivesASaveAndCanBeReIssued(); + + testADropIsReportedOnceAcrossRepeatedRederivations(); + testAnOfflineTickReportsNoDrops(); + + testATabSwitchRetargetsTheQueueAndLeavesTheWakeupStanding(); + testGoingIdleKeepsTheParkHoldWhileARetargetDropsIt(); + testOnlyANoProjectTickClearsTheWakeup(); + testATickThatLostNothingNeitherPrintsNorDisturbsTheDropMemo(); + + testARollingTransportDefersOfflineWorkAndKeepsTheHold(); + testOnlineWorkProceedsWhileTheTransportRolls(); + testOfflineWorkStaysGatedWhileOnlineDrainsInTheSameTick(); + testAHoldDropsWhenItsTrackStopsOwingOfflineWorkWhileRolling(); + testAGatedTickStillGoesIdleWithNothingLeftToConverge(); + + testTheSpanOpensOnceAndClosesOnceHoweverManyTimesItIsAsked(); + testTheSpanOutlastsADebounceWaitButNeverATransportDeferral(); + testAnAbortEndsTheSpanSoNoLaterSwitchNestsUnderIt(); + testOneSwitchAndItsMultiTickParkingAreOneSpan(); + testADrainNoSwitchOpenedMintsOnePointOfItsOwn(); + + testAStateLoadStopsTheDrainWhereItStandsRatherThanFinishingTheTrack(); + testTheWholePostLoadReconvergenceIsOneSpanHoweverFarTheParkingGot(); + testARevertBetweenTicksLeavesTheLoopAsleepOnAStaleModel(); + + testTheDirectionReadsOnlineWhileTheParksOwnOfflineWorkWaitsOutItsHold(); + testAnOfflineOnlyQueueReadsIdleUntilItsHoldMatures(); + testARollingTransportHidesOfflineWorkFromTheDirectionButNeverOnline(); + testTheDirectionHoldsThroughTheDrainsOwnMessagePump(); + + testNoTerminatorLeavesTheSpanOpenWithTheLoopAsleep(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_fx_state.cpp b/tests/test_fx_state.cpp new file mode 100644 index 0000000..0ce5b1b --- /dev/null +++ b/tests/test_fx_state.cpp @@ -0,0 +1,258 @@ +// Standalone tests for reasampler::view's derived FX state — no REAPER, no test +// framework. Two halves: what desiredFxState decides is owed, and how one work +// item resolves against the chain as it stands (the chain mutates while a track +// is parked, which is the whole reason a slot cannot be the key). + +#include "../src/core/view/fx_state.h" + +#include +#include + +using namespace reasampler; +using namespace reasampler::view; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// -- helpers ----------------------------------------------------------------- + +namespace { + +TrackSnapshot recordWithFx(std::vector fx) { + TrackSnapshot snap; + snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1; + snap.fxOffline = std::move(fx); + return snap; +} + +// A Design leaf parked with three identified FX, the middle one offline before +// the park because the USER put it there. +ViewModeModel parkedWithThreeFx() { + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); + vm.storeSnapshot("{T}", recordWithFx({{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}})); + return vm; +} + +const FxTrackWork* workFor(const std::vector& work, const std::string& guid) { + for (const FxTrackWork& w : work) + if (w.trackGuid == guid) return &w; + return nullptr; +} + +bool writeAt(const FxRestoreResolution& res, int fxIndex, bool offline) { + for (const FxOfflineWrite& w : res.writes) + if (w.fxIndex == fxIndex) return w.offline == offline; + return false; +} + +bool anyWriteAt(const FxRestoreResolution& res, int fxIndex) { + for (const FxOfflineWrite& w : res.writes) + if (w.fxIndex == fxIndex) return true; + return false; +} + +} // namespace + +// -- desiredFxState: what is owed -------------------------------------------- + +static void testActiveTrackIsOwedOnlineInactiveIsOwedOffline() { + ViewModeModel vm = parkedWithThreeFx(); + vm.membership().tag("{P}", kArrangeModeId); + vm.storeSnapshot("{P}", recordWithFx({{"{FX-P}", 0}})); + + // Active mode is Arrange: {T} (Design) stays parked, {P} is coming back. + const std::vector work = desiredFxState(vm, kArrangeModeId, {"{T}", "{P}"}); + CHECK(work.size() == 2); + CHECK(workFor(work, "{P}") && workFor(work, "{P}")->kind == FxWorkKind::Online); + CHECK(workFor(work, "{T}") && workFor(work, "{T}")->kind == FxWorkKind::Offline); + + // Offline work names no targets: only the shell can read the live chain, and + // that chain IS the target set. + CHECK(workFor(work, "{T}") && workFor(work, "{T}")->targets.empty()); + CHECK(workFor(work, "{P}") && workFor(work, "{P}")->targets.size() == 1); +} + +static void testOnlineWorkSortsBeforeOfflineWork() { + // GUIDs chosen so plain GUID order would put every Offline track FIRST — the + // ordering under test has to beat the map's own order to pass. + ViewModeModel vm; + vm.membership().tag("{A-park}", kDesignModeId); + vm.membership().tag("{B-park}", kDesignModeId); + vm.membership().tag("{Y-back}", kArrangeModeId); + vm.membership().tag("{Z-back}", kArrangeModeId); + for (const char* g : {"{A-park}", "{B-park}", "{Y-back}", "{Z-back}"}) + vm.storeSnapshot(g, recordWithFx({{"{FX}", 0}})); + + const std::vector work = + desiredFxState(vm, kArrangeModeId, {"{A-park}", "{B-park}", "{Y-back}", "{Z-back}"}); + CHECK(work.size() == 4); + // Onlining restores state the user owns and is 2-6x the cost of offlining; + // offlining is opportunistic reclaim and may be deferred indefinitely. + CHECK(work[0].trackGuid == "{Y-back}" && work[0].kind == FxWorkKind::Online); + CHECK(work[1].trackGuid == "{Z-back}" && work[1].kind == FxWorkKind::Online); + CHECK(work[2].trackGuid == "{A-park}" && work[2].kind == FxWorkKind::Offline); + CHECK(work[3].trackGuid == "{B-park}" && work[3].kind == FxWorkKind::Offline); + + // Deterministic: a re-derivation yields the identical list, so a drain's + // position in it survives one. + CHECK(desiredFxState(vm, kArrangeModeId, {"{A-park}", "{B-park}", "{Y-back}", "{Z-back}"}) == + work); +} + +static void testUserOfflinedPluginIsNeverBlanketOnlined() { + // The property this whole split exists for. {FX-B} was offline before the + // park because the user offlined it; coming back must leave it offline. + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + + const std::vector work = desiredFxState(vm, kDesignModeId, {"{T}"}); + CHECK(work.size() == 1); + CHECK(work[0].kind == FxWorkKind::Online); + + const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-A}", "{FX-B}", "{FX-C}"}); + CHECK(res.writes.size() == 3); + CHECK(res.drops.total() == 0); + CHECK(writeAt(res, 0, false)); + CHECK(writeAt(res, 1, true)); // stays offline — NOT blanket-onlined + CHECK(writeAt(res, 2, false)); +} + +static void testReleasedFxHalfIsOwedNothing() { + // The chain is already back online; the record survives only for its flags. + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + vm.releaseSnapshotFx("{T}"); + + CHECK(desiredFxState(vm, kDesignModeId, {"{T}"}).empty()); + CHECK(fxRestoreTargets(vm, "{T}").empty()); + + // ...and the mirror: a record whose FLAG half is spent still owes its FX. + ViewModeModel vm2 = parkedWithThreeFx(); + CHECK(vm2.setActiveMode(kDesignModeId)); + vm2.releaseSnapshotFlags("{T}"); + const std::vector work = desiredFxState(vm2, kDesignModeId, {"{T}"}); + CHECK(work.size() == 1); + CHECK(work.size() == 1 && work[0].targets.size() == 3); +} + +static void testDeadAndUnparkedTracksAreOwedNothing() { + ViewModeModel vm = parkedWithThreeFx(); + // The track is gone from the project: there is nothing to write to. + CHECK(desiredFxState(vm, kArrangeModeId, {}).empty()); + + // A track with no park record is owed nothing either way — that is also how a + // REFUSED park stays refused: it wrote no record, so no reclaim targets it. + ViewModeModel bare; + bare.membership().tag("{U}", kDesignModeId); + CHECK(desiredFxState(bare, kArrangeModeId, {"{U}"}).empty()); +} + +// -- resolveFxWork: against the chain as it stands ---------------------------- + +static void testOfflineWorkTargetsEveryLiveSlot() { + ViewModeModel vm = parkedWithThreeFx(); // active mode Arrange ⇒ {T} parks + const std::vector work = desiredFxState(vm, kArrangeModeId, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + // Two plugins added while parked, one of which REAPER reports no GUID for. + // Reclaim has to reach those too, which is why offline work keys by slot. + const FxRestoreResolution res = + resolveFxWork(work[0], {"{FX-A}", "", "{FX-B}", "{FX-C}", "{FX-NEW}"}); + CHECK(res.writes.size() == 5); + CHECK(res.drops.total() == 0); + for (int i = 0; i < 5; ++i) CHECK(writeAt(res, i, true)); +} + +static void testReorderRestoreLandsEachPluginItsOwnState() { + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + const std::vector work = desiredFxState(vm, kDesignModeId, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + // Reordered to C, A, B while parked. + const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-C}", "{FX-A}", "{FX-B}"}); + CHECK(res.writes.size() == 3); + CHECK(res.drops.total() == 0); + CHECK(writeAt(res, 1, false)); // A + CHECK(writeAt(res, 2, true)); // B's captured offline followed B, not slot 1 + CHECK(writeAt(res, 0, false)); // C +} + +static void testDeletedFxIsDroppedExplicitly() { + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + const std::vector work = desiredFxState(vm, kDesignModeId, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + // B deleted while parked. + const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-A}", "{FX-C}"}); + CHECK(res.writes.size() == 2); + CHECK(res.drops.missingIdentity == 1); + CHECK(writeAt(res, 0, false)); // A + CHECK(writeAt(res, 1, false)); // C — and NOT B's captured `true` + CHECK(!describeFxRestoreDrops(res.drops, 1).empty()); +} + +static void testAddedFxIsLeftAlone() { + ViewModeModel vm = parkedWithThreeFx(); + CHECK(vm.setActiveMode(kDesignModeId)); + const std::vector work = desiredFxState(vm, kDesignModeId, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + + const FxRestoreResolution res = + resolveFxWork(work[0], {"{FX-NEW}", "{FX-A}", "{FX-B}", "{FX-C}"}); + CHECK(res.writes.size() == 3); + CHECK(res.drops.total() == 0); + CHECK(!anyWriteAt(res, 0)); // the added FX is never written + CHECK(writeAt(res, 1, false)); + CHECK(writeAt(res, 2, true)); + CHECK(writeAt(res, 3, false)); +} + +static void testSlotKeyedRecordStillRestoresByPosition() { + // A record lifted from a project saved before FX identity existed: position + // is all its bytes can support, and the targets carry that keying through. + ViewModeModel vm; + vm.membership().tag("{T}", kDesignModeId); + CHECK(vm.setActiveMode(kDesignModeId)); + TrackSnapshot snap = recordWithFx({{"", 0}, {"", 1}, {"", 0}}); + snap.fxKeying = FxKeying::Slot; + vm.storeSnapshot("{T}", snap); + + const std::vector targets = fxRestoreTargets(vm, "{T}"); + CHECK(targets.size() == 3); + CHECK(!targets.empty() && targets[0].keying == FxKeying::Slot); + + const std::vector work = desiredFxState(vm, kDesignModeId, {"{T}"}); + CHECK(work.size() == 1); + if (work.empty()) return; + const FxRestoreResolution res = resolveFxWork(work[0], {"{FX-X}", "{FX-Y}", "{FX-Z}"}); + CHECK(res.writes.size() == 3); + CHECK(res.drops.total() == 0); + CHECK(writeAt(res, 0, false)); + CHECK(writeAt(res, 1, true)); + CHECK(writeAt(res, 2, false)); +} + +int main() { + testActiveTrackIsOwedOnlineInactiveIsOwedOffline(); + testOnlineWorkSortsBeforeOfflineWork(); + testUserOfflinedPluginIsNeverBlanketOnlined(); + testReleasedFxHalfIsOwedNothing(); + testDeadAndUnparkedTracksAreOwedNothing(); + + testOfflineWorkTargetsEveryLiveSlot(); + testReorderRestoreLandsEachPluginItsOwnState(); + testDeletedFxIsDroppedExplicitly(); + testAddedFxIsLeftAlone(); + testSlotKeyedRecordStillRestoresByPosition(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} 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; +} diff --git a/tests/test_view_fx_park.cpp b/tests/test_view_fx_park.cpp deleted file mode 100644 index 57e5e52..0000000 --- a/tests/test_view_fx_park.cpp +++ /dev/null @@ -1,197 +0,0 @@ -// Standalone tests for the Design View park surface's pure decisions — no -// REAPER, no test framework. -// -// The properties under test: a pre-park snapshot is never taken from a chain a -// park has already touched, the refusal that follows names its tracks and is -// reported once per changed set, and one mode apply leaves at most ONE undo -// point — none at all when a reapply found everything already where it wanted it. - -#include "../src/shell/view/view_fx_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 - -#include -#include -#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) - -// -- may this chain be snapshotted? ------------------------------------------- -// -// Rationale: the snapshot-source invariant (view_fx_park.h, this directory's -// CLAUDE.md). - -// The live values the fold reads, keyed by Flag — so an assertion names the flag -// it varies rather than a position in makeParkPlan's op order. -struct FlagValues { - int showInTcp = 0, showInMixer = 0, mainSend = 0, fxEnable = 0; - - int operator()(Flag f) const { - switch (f) { - case Flag::ShowInTcp: return showInTcp; - case Flag::ShowInMixer: return showInMixer; - case Flag::MainSend: return mainSend; - case Flag::FxEnable: return fxEnable; - } - return -1; - } -}; - -static void testAChainSittingAtEveryValueTheParkWouldWriteReadsAsParked() { - // The four zeros are what a parked track's driven flags actually read; if - // makeParkPlan ever writes something else, this is the test that says so. - const TrackPlan park = makeParkPlan("{A}", /*fxCount=*/0); - - CHECK(park.flags.size() == 4); - CHECK(parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 0, 0})); -} - -static void testOneFlagStillAtTheUsersValueMeansNoParkReachedTheChain() { - const TrackPlan park = makeParkPlan("{A}", /*fxCount=*/0); - - // Each case varies exactly one flag, so together they also prove all four are - // in the plan: a missing op makes its case read parked and fail here. - CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{1, 0, 0, 0})); - CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{0, 1, 0, 0})); - CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 1, 0})); - CHECK(!parkFlagsAlreadyApplied(park.flags, FlagValues{0, 0, 0, 1})); -} - -static void testAnEmptyPlanProvesNothing() { - CHECK(!parkFlagsAlreadyApplied(std::vector{}, FlagValues{0, 0, 0, 0})); -} - -static void testEitherHalfOfTheChainReadingParkedIsEnoughToRefuse() { - // Disjunction pinned; rationale at chainReadsParked (view_fx_park.h). - CHECK(!chainReadsParked(/*parkFlagsRead=*/false, /*anyFxOffline=*/false)); - CHECK(chainReadsParked(/*parkFlagsRead=*/true, /*anyFxOffline=*/false)); - CHECK(chainReadsParked(/*parkFlagsRead=*/false, /*anyFxOffline=*/true)); - CHECK(chainReadsParked(/*parkFlagsRead=*/true, /*anyFxOffline=*/true)); -} - -static void testACleanChainIsSnapshottedThenParked() { - CHECK(decidePark(/*haveSnapshot=*/false, /*chainReadsParked=*/false) == - ParkAction::SnapshotThenPark); -} - -static void testAHeldSnapshotIsNeverOverwrittenWhateverTheChainReads() { - // The held snapshot IS the pre-park truth, so the chain is not consulted — - // which is what lets the shell skip the live flag reads on this path. - CHECK(decidePark(/*haveSnapshot=*/true, /*chainReadsParked=*/false) == ParkAction::ParkOnly); - CHECK(decidePark(/*haveSnapshot=*/true, /*chainReadsParked=*/true) == ParkAction::ParkOnly); -} - -static void testAParkedChainWithNoSnapshotIsRefusedRatherThanResnapshotted() { - // The defect this whole section exists for: the truth is gone, so the only - // non-destructive act is to leave the track alone. Snapshotting here commits - // park state as the user's state and no later restore can undo it. - CHECK(decidePark(/*haveSnapshot=*/false, /*chainReadsParked=*/true) == ParkAction::Refuse); -} - -static void testRefusalNamesEveryRefusedTrackAndSaysNothingWhenNoneWere() { - CHECK(describeRefusedParks({}).empty()); - - const std::string one = describeRefusedParks({"Bass"}); - CHECK(one.find("1 track ") != std::string::npos); - CHECK(one.find("Bass") != std::string::npos); - - const std::string two = describeRefusedParks({"Bass", "Drum bus"}); - CHECK(two.find("2 tracks ") != std::string::npos); - CHECK(two.find("Bass") != std::string::npos); - CHECK(two.find("Drum bus") != std::string::npos); -} - -static void testRefusalRecoveryNamesThePerFxHalfAndAssertsNoCause() { - const std::string msg = describeRefusedParks({"Bass"}); - - // I_FXEN is the chain bypass: a user who restores only the four flags leaves - // every individually offlined FX offline and walks straight back into a - // refusal, so the recovery has to spell the per-FX step out. - CHECK(msg.find("every FX in its chain online") != std::string::npos); - // And it must not name a cause: the pair has several routes, and on a track - // the user themselves keeps hidden/bypassed there was no lost state at all. - CHECK(msg.find("undo") == std::string::npos); - CHECK(msg.find("lost") == std::string::npos); -} - -// -- the refusal memo's print-suppression gate -------------------------------- -// -// reportRefusedParks' own memo (owner + last-reported names, both REAPER-side -// statics) can't be driven from here, but the pure gate behind it can: prints -// (i.e. updates the memo) on any change, stays silent on an exact repeat, and -// treats a fresh empty set as a change too, so a later real refusal is never -// mistaken for a repeat of one that already healed. - -static void testUnchangedOwnerAndSetStaysSilent() { - CHECK(!shouldReport(/*sameOwnerAsLast=*/true, {"Bass"}, {"Bass"})); -} - -static void testADifferentNamedSetReports() { - CHECK(shouldReport(/*sameOwnerAsLast=*/true, {"Bass"}, {"Bass", "Drum bus"})); -} - -static void testADifferentOwnerReportsEvenWithTheSameNames() { - // Two alternating project tabs must not suppress each other's first refusal. - CHECK(shouldReport(/*sameOwnerAsLast=*/false, {"Bass"}, {"Bass"})); -} - -static void testAFreshEmptySetReportsAndResetsTheMemo() { - CHECK(shouldReport(/*sameOwnerAsLast=*/true, {"Bass"}, {})); - // Already empty, same owner: nothing changed, stays silent. - CHECK(!shouldReport(/*sameOwnerAsLast=*/true, {}, {})); -} - -// -- 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 and - // every FX already sit where the plan wants them. Opening a project must not - // cost the user an undo step. - CHECK(!applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/false)); -} - -int main() { - testAChainSittingAtEveryValueTheParkWouldWriteReadsAsParked(); - testOneFlagStillAtTheUsersValueMeansNoParkReachedTheChain(); - testAnEmptyPlanProvesNothing(); - testEitherHalfOfTheChainReadingParkedIsEnoughToRefuse(); - testACleanChainIsSnapshottedThenParked(); - testAHeldSnapshotIsNeverOverwrittenWhateverTheChainReads(); - testAParkedChainWithNoSnapshotIsRefusedRatherThanResnapshotted(); - testRefusalNamesEveryRefusedTrackAndSaysNothingWhenNoneWere(); - testRefusalRecoveryNamesThePerFxHalfAndAssertsNoCause(); - - testUnchangedOwnerAndSetStaysSilent(); - testADifferentNamedSetReports(); - testADifferentOwnerReportsEvenWithTheSameNames(); - testAFreshEmptySetReportsAndResetsTheMemo(); - - testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite(); - testAReapplyThatWroteSomethingLeavesAPoint(); - testAReapplyThatWroteNothingLeavesNoPointAtAll(); - - if (g_fail == 0) std::printf("All tests passed.\n"); - return g_fail ? 1 : 0; -} diff --git a/tests/test_view_mode_model.cpp b/tests/test_view_mode_model.cpp index a9e4427..3bbf76a 100644 --- a/tests/test_view_mode_model.cpp +++ b/tests/test_view_mode_model.cpp @@ -10,7 +10,7 @@ // 4. show-both leaf never appears in a park op-list and is visible in all modes. // 5. Unknown/stale GUID tolerated (ignore-and-prune, no crash). // 6. JSON round-trip lossless: modes + membership + show-both + snapshots + active. -// 7. planToggle park path: fxOffline is empty (shell-expands-FX contract). +// 7. planToggle plans the FLAG domain only — FX is derived (core/view/fx_state). // 8. Per-FX offline is keyed by FX identity, and the v1 (slot-keyed) blob lifts // into that keying without losing its restore. // 9. Nested-folder toggle: the snapshot store/clear lifecycle survives a re-park @@ -66,7 +66,7 @@ static int flagValue(const TrackPlan& p, Flag f) { static void testSerializeGoldenLiteral() { ViewModeModel vm; CHECK(vm.serialize() == - "{\"version\":2,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\"," + "{\"version\":3,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\"," "\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\"," "\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[]," "\"snapshots\":[],\"lanes\":[],\"soloCache\":[]}"); @@ -287,16 +287,15 @@ static void testRestoreRoundTripSnapshotValues() { snap.mainSend = 0; // user had it OUT of the mix for their own reason snap.fxEnable = 1; // Slot 1's plugin was already offline before parking; each entry carries the - // identity of the FX it came from. + // identity of the FX it came from. The FX half's own restore is fx_state's — + // test_fx_state covers it; here it only has to survive alongside the flags. snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}}; - TrackPlan park = makeParkPlan("{T}", /*fxCount=*/3); + TrackPlan park = makeParkPlan("{T}"); CHECK(flagValue(park, Flag::ShowInTcp) == 0); CHECK(flagValue(park, Flag::ShowInMixer) == 0); CHECK(flagValue(park, Flag::MainSend) == 0); CHECK(flagValue(park, Flag::FxEnable) == 0); - CHECK(park.fxOffline.size() == 3); - for (const auto& op : park.fxOffline) CHECK(op.offline == true); TrackPlan restore = makeRestorePlan("{T}", snap); // Every flag returns to its CAPTURED value — not a hardcoded "on". @@ -304,14 +303,6 @@ static void testRestoreRoundTripSnapshotValues() { CHECK(flagValue(restore, Flag::ShowInMixer) == 1); CHECK(flagValue(restore, Flag::MainSend) == 0); // the "already at 0 stays 0" case CHECK(flagValue(restore, Flag::FxEnable) == 1); - CHECK(restore.fxOffline.size() == 3); - CHECK(restore.fxOffline[0].offline == false); - CHECK(restore.fxOffline[1].offline == true); // was offline pre-park ⇒ stays offline - CHECK(restore.fxOffline[2].offline == false); - // Each restore op names the FX it was captured from, not just a position — - // resolveFxRestore has something to key on even if the chain moved. - CHECK(restore.fxOffline[1].keying == FxKeying::Identity); - CHECK(restore.fxOffline[1].fxGuid == "{FX-B}"); // A snapshot entirely at 0 must restore entirely to 0 (no default leaks in). TrackSnapshot zero; // all zeros, empty fxOffline @@ -320,7 +311,6 @@ static void testRestoreRoundTripSnapshotValues() { CHECK(flagValue(rz, Flag::ShowInMixer) == 0); CHECK(flagValue(rz, Flag::MainSend) == 0); CHECK(flagValue(rz, Flag::FxEnable) == 0); - CHECK(rz.fxOffline.empty()); // End-to-end via planToggle: a leaf tagged Design, snapshotted, parked while in // Arrange, then restored when we toggle back to Design. @@ -500,29 +490,98 @@ static void testMalformedJson() { } } -// -- 7. planToggle park path: fxOffline is empty (shell-expands-FX contract) -- +// -- 7. planToggle plans the FLAG domain only -------------------------------- // -// planToggle calls makeParkPlan(guid, /*fxCount=*/0) for each inactive leaf. -// The D2 shell is responsible for expanding per-FX offline ops using -// TrackFX_GetCount — the pure model has no access to REAPER FX counts at plan -// time. This test pins that contract so a regression that passes a non-zero -// count (and emits FX ops prematurely) is caught immediately. -// The direct makeParkPlan(guid, 3) path (non-zero fxCount) is covered by -// testRestoreRoundTripSnapshotValues above. +// A TogglePlan carries the four driven scalars and nothing else; per-FX offline +// is derived from view state (core/view/fx_state), not planned beside them. -static void testPlanToggleParkHasEmptyFxOffline() { +static void testPlanTogglePlansFlagsOnly() { ViewModeModel vm; FolderTree tree; tree.nodes.push_back(FolderNode{"{LEAF}", "", false}); + tree.nodes.push_back(FolderNode{"{BACK}", "", false}); vm.membership().tag("{LEAF}", kDesignModeId); + vm.membership().tag("{BACK}", kArrangeModeId); + TrackSnapshot snap; + snap.showInTcp = 1; + snap.fxOffline = {{"{FX-A}", 1}}; + vm.storeSnapshot("{BACK}", snap); - // Toggle to Arrange: {LEAF} is inactive ⇒ park plan emitted. + // Toggle to Arrange: {LEAF} is inactive ⇒ parked; {BACK} returns ⇒ restored. auto plan = vm.planToggle(tree, kArrangeModeId); CHECK(plan.park.size() == 1); - // The park plan must have an empty fxOffline — the shell expands FX ops. - CHECK(plan.park[0].fxOffline.empty()); - // Scalar flags must still be present (the four park zeros). - CHECK(plan.park[0].flags.size() == 4); + CHECK(plan.park[0].flags.size() == 4); // the four park zeros, nothing else + CHECK(plan.restore.size() == 1); + CHECK(plan.restore[0].flags.size() == 4); +} + +// A record whose flag half a restore already spent must never be replanned: its +// values are zeroed, and restoring THOSE would silently re-park the track. +static void testReleasedFlagHalfIsNotReplanned() { + ViewModeModel vm; + FolderTree tree; + tree.nodes.push_back(FolderNode{"{T}", "", false}); + vm.membership().tag("{T}", kDesignModeId); + TrackSnapshot snap; + snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1; + snap.fxOffline = {{"{FX-A}", 1}}; + vm.storeSnapshot("{T}", snap); + + CHECK(vm.holdsSnapshotFlags("{T}")); + CHECK(vm.holdsSnapshotFx("{T}")); + CHECK(restoreFor(vm.planToggle(tree, kDesignModeId), "{T}") != nullptr); + + vm.releaseSnapshotFlags("{T}"); + // The FX half OUTLIVES the flag half — it is the only record of which plugins + // the user had offline before the park. + CHECK(!vm.holdsSnapshotFlags("{T}")); + CHECK(vm.holdsSnapshotFx("{T}")); + CHECK(vm.snapshot("{T}") != nullptr); + CHECK(vm.snapshot("{T}")->fxOffline.size() == 1); + CHECK(restoreFor(vm.planToggle(tree, kDesignModeId), "{T}") == nullptr); + + // Spending the second half is what retires the record. + vm.releaseSnapshotFx("{T}"); + CHECK(!vm.holdsSnapshotFx("{T}")); + CHECK(vm.snapshot("{T}") == nullptr); + + // The other release order retires it just the same. + vm.storeSnapshot("{T}", snap); + vm.releaseSnapshotFx("{T}"); + CHECK(vm.holdsSnapshotFlags("{T}") && !vm.holdsSnapshotFx("{T}")); + CHECK(vm.snapshot("{T}") && vm.snapshot("{T}")->fxOffline.empty()); + vm.releaseSnapshotFlags("{T}"); + CHECK(vm.snapshot("{T}") == nullptr); + + // Releasing a half of a record that does not exist is a no-op, not a mint. + vm.releaseSnapshotFlags("{GONE}"); + vm.releaseSnapshotFx("{GONE}"); + CHECK(vm.snapshot("{GONE}") == nullptr); +} + +// A half-released record has to survive the .rpp: the FX half outliving the flag +// half is only useful if a save-while-draining reloads in the same state. +static void testHalfReleasedRecordRoundTrips() { + ViewModeModel vm; + TrackSnapshot snap; + snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1; + snap.fxOffline = {{"{FX-A}", 1}, {"{FX-B}", 0}}; + vm.storeSnapshot("{T}", snap); + vm.releaseSnapshotFlags("{T}"); + + auto back = ViewModeModel::deserialize(vm.serialize()); + CHECK(back.has_value()); + CHECK(back && *back == vm); + CHECK(back && !back->holdsSnapshotFlags("{T}")); + CHECK(back && back->holdsSnapshotFx("{T}")); + CHECK(back && back->snapshot("{T}") && back->snapshot("{T}")->fxOffline.size() == 2); + + // Absent means held: a fully-held record writes neither key, so every v1/v2 + // blob still loads as one nothing has spent. + ViewModeModel held; + held.storeSnapshot("{T}", snap); + CHECK(held.serialize().find("\"flagsHeld\"") == std::string::npos); + CHECK(held.serialize().find("\"fxHeld\"") == std::string::npos); } // -- 7b. Untagged leaves are managed by the mode system ---------------------- @@ -583,9 +642,6 @@ static void testUntaggedLeavesManagedByModeSystem() { CHECK(flagValue(*r, Flag::ShowInMixer) == 1); CHECK(flagValue(*r, Flag::MainSend) == 0); // captured 0 comes back 0 CHECK(flagValue(*r, Flag::FxEnable) == 1); - CHECK(r->fxOffline.size() == 2); - CHECK(r->fxOffline[0].offline == false); - CHECK(r->fxOffline[1].offline == true); } } @@ -758,13 +814,20 @@ using LiveFlags = std::map; // Runs one applyMode-equivalent toggle against `vm` + `live`. `guard` selects the // fixed behavior vs. the original buggy (snapshot-every-park) one. // Returns nothing; mutates `vm` snapshots/active mode and `live` flags in place, -// exactly mirroring view.cpp's park then restore then setActiveMode ordering. +// mirroring view.cpp's park then restore then setActiveMode ordering: guarded park +// gates on holdsSnapshotFlags (parkTrack's own gate, current shell — there is no +// refusal branch any more, see view_park.h) and guarded restore spends the flag half +// through releaseSnapshotFlags (restoreTrack's own call), not a full clearSnapshot. +// The FX half of the two-half record is never exercised here (nothing sets fxHeld +// false), so it stays held throughout and never blocks a re-capture the way +// flagsHeld does — the one place this harness is narrower than the real record. // -// Guarded, the park mirrors decidePark's three ways: a held snapshot parks -// without recapturing, a chain that already reads parked with no snapshot is -// REFUSED (left exactly as found), and only a clean chain is snapshotted. The one -// `live` flag stands in for the whole park-flag-plus-FX fold the shell reads — -// the model half of the defect is identical either way. +// `live[guid] == 0` doubles as this harness's single-flag stand-in for +// parkTrack's real chainReadsParked (all four driven flags at their parked +// value) — the harness tracks one representative flag, not four, so "already at +// its parked value" is the whole predicate here. `vm.snapshot(guid) == nullptr` +// mirrors parkTrack's own gate for "no record of any kind" — see that function's +// header doc for why an FX-held-only record must not reach EXPOSE. void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tree, const std::string& target, bool guard) { TogglePlan plan = vm.planToggle(tree, target); @@ -774,8 +837,14 @@ void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tre if (tp.flags.empty()) continue; const std::string& guid = tp.flags.front().guid; if (guard) { - if (vm.snapshot(guid) == nullptr) { - if (live[guid] == 0) continue; // reads parked, no snapshot — refuse + if (!vm.holdsSnapshotFlags(guid)) { + if (!vm.snapshot(guid) && live[guid] == 0) { + // EXPOSE: hand it back visible rather than adopt the + // park-shaped read as intent. No snapshot stored; this cycle + // does not park the track. + live[guid] = 1; + continue; + } TrackSnapshot snap; snap.showInTcp = live[guid]; vm.storeSnapshot(guid, snap); @@ -788,13 +857,13 @@ void simulateApplyMode(ViewModeModel& vm, LiveFlags& live, const FolderTree& tre live[guid] = 0; // park hides it } - // RESTORE: write snapshot flag back, then drop the snapshot. + // RESTORE: write snapshot flag back, then spend the flag half. for (const auto& tp : plan.restore) { if (tp.flags.empty()) continue; const std::string& guid = tp.flags.front().guid; if (const TrackSnapshot* snap = vm.snapshot(guid)) live[guid] = snap->showInTcp; // restore the captured visible flag - vm.clearSnapshot(guid); + vm.releaseSnapshotFlags(guid); } vm.setActiveMode(target); @@ -883,70 +952,51 @@ static void testNestedToggleSnapshotSurvivesRepark() { // -- 9b. Reload strand: snapshots gone, live flags still parked --------------- // -// The pair the park refusal exists for, driven through the same harness. A model -// replaced without the project rolling back with it — an unreadable view_state, a -// snapshot reconciled away while its track was deleted, a hand or script edit — -// leaves tracks sitting at parked values with nothing recording what they were -// before. Snapshotting there commits the parked state as the user's, and the very -// next toggle writes it back over whatever they have since fixed by hand. +// A model replaced without the project rolling back with it — an unreadable +// view_state, a snapshot reconciled away while its track was deleted, a hand or +// script edit — leaves tracks sitting at parked values with nothing recording what +// they were before. Daniel's ruling: hand the track back rather than either +// adopting the parked read as intent (silently wipes a hand repair on the very +// next restore, or hides an untouched track forever) or refusing outright (the +// retired `decidePark`). EXPOSE does neither: no snapshot stored, the flags +// driven visible, and the very next reapply — no user action needed — finds an +// honest chain and captures/parks it normally. -static void testReloadedModelWithParkedTracksRefusesRatherThanResnapshotting() { +static void testReloadedModelWithParkedTracksIsExposedNotAdopted() { const FolderTree tree = nestedTree(); - // GUARDED (fixed shell). - { - ViewModeModel vm; - vm.membership().tag("{L1}", kDesignModeId); - LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}}; + ViewModeModel vm; + vm.membership().tag("{L1}", kDesignModeId); + LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}}; - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); - CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); - CHECK(vm.snapshot("{L2}") != nullptr); + simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); + CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); + CHECK(vm.snapshot("{L2}") != nullptr); - // The reload: the model comes back with no snapshots while the project's - // tracks are still parked. - vm.clearSnapshot("{L2}"); - vm.clearSnapshot("{L3}"); + // The reload: the model comes back with no snapshots while the project's + // tracks are still parked -- park-shaped, no record. + vm.clearSnapshot("{L2}"); + vm.clearSnapshot("{L3}"); - // The load tick reapplies the saved active mode. Both leaves are refused — - // nothing written, and nothing false captured. - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); - CHECK(vm.snapshot("{L2}") == nullptr && vm.snapshot("{L3}") == nullptr); - CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); + // The load-tick reapply hits the no-record/park-shaped pair on both leaves. + // EXPOSE fires for each: handed back visible, no snapshot stored -- neither + // leaf needed a hand recovery first, and {L3} (never touched by hand in the + // old test) comes back on its own exactly like {L2}. + simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); + CHECK(live["{L2}"] == 1 && live["{L3}"] == 1); + CHECK(vm.snapshot("{L2}") == nullptr && vm.snapshot("{L3}") == nullptr); - // The documented hand recovery, on {L2} only, while still in Design. - // Toggling back must leave it where the user put it: with no snapshot - // there is nothing to restore FROM, so the restore writes nothing. - live["{L2}"] = 1; - simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true); - CHECK(live["{L2}"] == 1); // the hand fix survives - CHECK(live["{L3}"] == 0); // never recovered — stuck, but never falsely committed + // Self-healing: the chain no longer reads park-shaped, so the very next apply + // of the same target takes the normal path -- captures the (now-visible) + // truth and parks it correctly. + simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); + CHECK(live["{L2}"] == 0 && live["{L3}"] == 0); + CHECK(vm.snapshot("{L2}") != nullptr && vm.snapshot("{L3}") != nullptr); - // And {L2} is back under mode control from there: the next park sees a - // clean chain, captures the user's value, and restores it. - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/true); - CHECK(live["{L2}"] == 0 && vm.snapshot("{L2}") != nullptr); - simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true); - CHECK(live["{L2}"] == 1); - } - - // UNGUARDED (the defect): the reapply after the reload recaptures the parked - // zero, so the very next toggle writes it back over the hand recovery. - { - ViewModeModel vm; - vm.membership().tag("{L1}", kDesignModeId); - LiveFlags live{{"{L1}", 1}, {"{L2}", 1}, {"{L3}", 1}}; - - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/false); - vm.clearSnapshot("{L2}"); - vm.clearSnapshot("{L3}"); - simulateApplyMode(vm, live, tree, kDesignModeId, /*guard=*/false); - - live["{L2}"] = 1; // the same hand recovery - simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/false); - - CHECK(live["{L2}"] == 0); // wiped — the false snapshot won - } + // A normal round trip back to Arrange restores the captured (visible) truth -- + // the wipe the old adopt-based behavior produced is gone. + simulateApplyMode(vm, live, tree, kArrangeModeId, /*guard=*/true); + CHECK(live["{L2}"] == 1 && live["{L3}"] == 1); } // =========================================================================== @@ -2022,38 +2072,14 @@ static void testReconcilePrunesTheSoloCacheAlongsideSnapshots() { CHECK(design && design->count("{LIVE}") == 1); } -// -- Per-FX offline: identity keying and the v1 -> v2 snapshot ladder --------- +// -- Per-FX offline: the v1 -> v2 snapshot ladder ---------------------------- // -// The restore path end-to-end, at the seam the shell actually uses: a stored -// snapshot -> planToggle -> makeRestorePlan ops -> resolveFxRestore against the -// chain as it stands now. The chain mutations happen while the track is parked, -// which is the whole reason a slot cannot be the key. +// The persisted shape only. Resolving a captured FX state against the live chain +// moved to core/view/fx_state — test_fx_state owns those cases. namespace { -// The plan's restore ops for one parked-then-reactivated leaf. -std::vector restoreOpsFor(ViewModeModel& vm, const std::string& guid) { - FolderTree tree; - tree.nodes.push_back(FolderNode{guid, "", false}); - const TogglePlan plan = vm.planToggle(tree, kDesignModeId); - const TrackPlan* r = restoreFor(plan, guid); - return r ? r->fxOffline : std::vector{}; -} - -bool writeAt(const FxRestoreResolution& res, int fxIndex, bool offline) { - for (const FxOfflineWrite& w : res.writes) - if (w.fxIndex == fxIndex) return w.offline == offline; - return false; -} - -bool anyWriteAt(const FxRestoreResolution& res, int fxIndex) { - for (const FxOfflineWrite& w : res.writes) - if (w.fxIndex == fxIndex) return true; - return false; -} - -// A leaf tagged Design, parked with three identified FX — the state every case -// below starts from. +// A leaf tagged Design, parked with three identified FX. ViewModeModel parkedWithThreeFx() { ViewModeModel vm; vm.membership().tag("{T}", kDesignModeId); @@ -2066,46 +2092,6 @@ ViewModeModel parkedWithThreeFx() { } // namespace -static void testParkReorderRestoreLandsEachPluginItsOwnState() { - ViewModeModel vm = parkedWithThreeFx(); - // Reordered to C, A, B while parked. - const FxRestoreResolution res = - resolveFxRestore(restoreOpsFor(vm, "{T}"), {"{FX-C}", "{FX-A}", "{FX-B}"}); - - CHECK(res.writes.size() == 3); - CHECK(res.drops.total() == 0); - CHECK(writeAt(res, 1, false)); // A - CHECK(writeAt(res, 2, true)); // B's captured offline followed B, not slot 1 - CHECK(writeAt(res, 0, false)); // C -} - -static void testParkDeleteFxRestoreDropsExplicitly() { - ViewModeModel vm = parkedWithThreeFx(); - // B deleted while parked. - const FxRestoreResolution res = - resolveFxRestore(restoreOpsFor(vm, "{T}"), {"{FX-A}", "{FX-C}"}); - - CHECK(res.writes.size() == 2); - CHECK(res.drops.missingIdentity == 1); - CHECK(writeAt(res, 0, false)); // A - CHECK(writeAt(res, 1, false)); // C — and NOT B's captured `true` - CHECK(!describeFxRestoreDrops(res.drops, 1).empty()); -} - -static void testParkAddFxRestoreLeavesItAlone() { - ViewModeModel vm = parkedWithThreeFx(); - // A new plugin inserted at the head while parked. - const FxRestoreResolution res = resolveFxRestore( - restoreOpsFor(vm, "{T}"), {"{FX-NEW}", "{FX-A}", "{FX-B}", "{FX-C}"}); - - CHECK(res.writes.size() == 3); - CHECK(res.drops.total() == 0); - CHECK(!anyWriteAt(res, 0)); // the added FX is never written - CHECK(writeAt(res, 1, false)); - CHECK(writeAt(res, 2, true)); - CHECK(writeAt(res, 3, false)); -} - static void testV2WritesTheLegacySlotArrayBesideIdentities() { // The downgrade half of the ladder: a build that predates identity keying // reads "fxOffline" and skips "fx", so it keeps exactly the behavior it had @@ -2136,7 +2122,7 @@ static void testV2WritesTheLegacySlotArrayBesideIdentities() { asOlder->snapshot("{T}")->fxOffline[1].offline == 1); } -static void testLegacyBlobLiftsToSlotKeyingAndStillRestores() { +static void testLegacyBlobLiftsToSlotKeying() { // A view_state written before FX identity existed: no "fx" key anywhere. const char* v1 = "{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\"," @@ -2157,18 +2143,8 @@ static void testLegacyBlobLiftsToSlotKeyingAndStillRestores() { CHECK(snap && snap->fxOffline[1].offline == 1); CHECK(snap && snap->fxOffline[1].fxGuid.empty()); CHECK(snap && snap->showInTcp == 1 && snap->mainSend == 1); - - // It still restores — by position, which is all its bytes can support, and - // is exactly what the pre-change build would have done with them. - const std::vector ops = restoreOpsFor(*loaded, "{T}"); - CHECK(ops.size() == 3); - CHECK(!ops.empty() && ops[0].keying == FxKeying::Slot); - const FxRestoreResolution res = resolveFxRestore(ops, {"{FX-X}", "{FX-Y}", "{FX-Z}"}); - CHECK(res.writes.size() == 3); - CHECK(res.drops.total() == 0); - CHECK(writeAt(res, 0, false)); - CHECK(writeAt(res, 1, true)); - CHECK(writeAt(res, 2, false)); + // That it still RESTORES — by position, all its bytes can support — is + // test_fx_state's case; here the lift only has to preserve the keying. // Re-saving a lifted snapshot does NOT invent identities for it: the "fx" // key stays absent, and a second load reads the same slot-keyed shape. @@ -2202,11 +2178,13 @@ int main() { testJsonRoundTrip(); testEmptyModelRoundTrip(); testMalformedJson(); - testPlanToggleParkHasEmptyFxOffline(); + testPlanTogglePlansFlagsOnly(); + testReleasedFlagHalfIsNotReplanned(); + testHalfReleasedRecordRoundTrips(); testUntaggedLeavesManagedByModeSystem(); testTaggedLeafBehaviorUnchangedWithUntagged(); testNestedToggleSnapshotSurvivesRepark(); - testReloadedModelWithParkedTracksRefusesRatherThanResnapshotting(); + testReloadedModelWithParkedTracksIsExposedNotAdopted(); testReconcilePrunesOrphanedSnapshots(); testReconcileFullLiveSetIsNoOp(); testReconcileThenReparkLifecycleIntact(); @@ -2242,12 +2220,9 @@ int main() { testSoloCacheMalformedJson(); testReconcilePrunesTheSoloCacheAlongsideSnapshots(); - // Per-FX offline identity keying + the v1 -> v2 snapshot ladder - testParkReorderRestoreLandsEachPluginItsOwnState(); - testParkDeleteFxRestoreDropsExplicitly(); - testParkAddFxRestoreLeavesItAlone(); + // Per-FX offline: the v1 -> v2 snapshot ladder testV2WritesTheLegacySlotArrayBesideIdentities(); - testLegacyBlobLiftsToSlotKeyingAndStillRestores(); + testLegacyBlobLiftsToSlotKeying(); if (g_fail == 0) std::printf("All tests passed.\n"); return g_fail ? 1 : 0; diff --git a/tests/test_view_park.cpp b/tests/test_view_park.cpp new file mode 100644 index 0000000..5bed250 --- /dev/null +++ b/tests/test_view_park.cpp @@ -0,0 +1,205 @@ +// Standalone tests for the Design View park surface's pure decisions — no REAPER, no +// 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), the re-park union, the +// park-shaped chain read, and the EXPOSE report's wording. + +#include "../src/shell/view/view_park.h" + +#include "core/view/view_mode_model.h" // makeParkPlan — the park flags' only producer + +#include +#include +#include +#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) + +// -- what a park is allowed to address ---------------------------------------- +// +// trackFlagParm is the ONE mapping the park's pre-park read, the restore's write and +// the reconcile's repair all share; a mistyped parameter there is silent at compile +// time and drives the wrong track property forever. + +static void testEachDrivenFlagAddressesItsOwnReaperParameter() { + const std::set parms{trackFlagParm(Flag::ShowInTcp), + trackFlagParm(Flag::ShowInMixer), + trackFlagParm(Flag::MainSend), + trackFlagParm(Flag::FxEnable)}; + CHECK(parms.size() == 4); // no two flags collapse onto one parameter + CHECK(parms.count("B_SHOWINTCP") == 1); + CHECK(parms.count("B_SHOWINMIXER") == 1); + CHECK(parms.count("B_MAINSEND") == 1); + CHECK(parms.count("I_FXEN") == 1); +} + +static void testNoDrivenFlagCanEverAddressMuteOrSolo() { + // The never-touch-mute invariant, held at the one place a Flag becomes a REAPER + // parameter name. + for (const Flag f : {Flag::ShowInTcp, Flag::ShowInMixer, Flag::MainSend, Flag::FxEnable}) { + const std::string parm = trackFlagParm(f); + CHECK(parm != "B_MUTE"); + CHECK(parm != "I_SOLO"); + } +} + +static void testAParkPlanDrivesEveryDrivenFlagToZero() { + // The four zeros are what a parked track's driven flags actually read; if + // makeParkPlan ever writes something else, this is the test that says so. + const TrackPlan park = makeParkPlan("{A}"); + + CHECK(park.flags.size() == 4); + std::set parms; + for (const TrackFlagOp& op : park.flags) { + CHECK(op.guid == "{A}"); + CHECK(op.value == 0); + parms.insert(trackFlagParm(op.flag)); + } + CHECK(parms.size() == 4); +} + +// -- 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 +// union, the record's prior verbatim-keep would drop P silently -- the reclaimer +// offlines it with everything else, and no snapshot ever names it to bring it back. + +static void testAPluginAddedWhileRestoredJoinsTheUnionRatherThanBeingDropped() { + const std::vector prior{ + FxOfflineState{"{FX-A}", 1}, FxOfflineState{"{FX-B}", 0}}; + const std::vector live{ + FxOfflineState{"{FX-A}", 0}, FxOfflineState{"{FX-B}", 0}, FxOfflineState{"{FX-P}", 0}}; + + const std::vector merged = unionFxOfflineOnRepark(prior, live); + + CHECK(merged.size() == 3); + bool foundA = false, foundB = false, foundP = false; + for (const FxOfflineState& fx : merged) { + if (fx.fxGuid == "{FX-A}") { foundA = true; CHECK(fx.offline == 1); } // record wins + if (fx.fxGuid == "{FX-B}") { foundB = true; CHECK(fx.offline == 0); } // record wins + if (fx.fxGuid == "{FX-P}") { foundP = true; CHECK(fx.offline == 0); } // added from live + } + CHECK(foundA && foundB && foundP); +} + +static void testAPluginRemovedWhileRestoredStaysInTheUnionForResolveFxRestoreToDrop() { + // The union is not where a removed plugin's entry disappears -- resolveFxRestore + // owns that at restore time (missingIdentity, reported), so the merge must keep + // naming it rather than quietly forgetting it here. + const std::vector prior{ + FxOfflineState{"{FX-A}", 1}, FxOfflineState{"{FX-REMOVED}", 1}}; + const std::vector live{FxOfflineState{"{FX-A}", 0}}; + + const std::vector merged = unionFxOfflineOnRepark(prior, live); + + CHECK(merged.size() == 2); + bool foundRemoved = false; + for (const FxOfflineState& fx : merged) + if (fx.fxGuid == "{FX-REMOVED}") foundRemoved = true; + CHECK(foundRemoved); +} + +// -- chainReadsParked: the EXPOSE trigger, flags only ------------------------- +// +// The live values the fold reads, keyed by Flag -- so an assertion names the flag +// it varies rather than a position in makeParkPlan's op order. +struct FlagValues { + int showInTcp = 0, showInMixer = 0, mainSend = 0, fxEnable = 0; + + int operator()(Flag f) const { + switch (f) { + case Flag::ShowInTcp: return showInTcp; + case Flag::ShowInMixer: return showInMixer; + case Flag::MainSend: return mainSend; + case Flag::FxEnable: return fxEnable; + } + return -1; + } +}; + +static void testAChainSittingAtEveryParkedValueReadsAsParkShaped() { + const TrackPlan park = makeParkPlan("{A}"); + CHECK(chainReadsParked(park.flags, FlagValues{0, 0, 0, 0})); +} + +static void testOneFlagStillAtTheUsersValueMeansNotParkShaped() { + const TrackPlan park = makeParkPlan("{A}"); + + // Each case varies exactly one flag, so together they also prove all four are + // in the plan: a missing op makes its case read parked and fail here. + CHECK(!chainReadsParked(park.flags, FlagValues{1, 0, 0, 0})); + CHECK(!chainReadsParked(park.flags, FlagValues{0, 1, 0, 0})); + CHECK(!chainReadsParked(park.flags, FlagValues{0, 0, 1, 0})); + CHECK(!chainReadsParked(park.flags, FlagValues{0, 0, 0, 1})); +} + +static void testAnEmptyPlanProvesNothing() { + CHECK(!chainReadsParked(std::vector{}, FlagValues{0, 0, 0, 0})); +} + +static void testFxOfflineAloneIsNotFoldedIntoTheParkedRead() { + // chainReadsParked takes no FX argument at all: an FX-only-offline chain + // cannot make it read parked, by construction -- there is nothing here it + // could read. That is the resolved design question (see view_park.h): + // flags-fine/FX-offline stays on the ordinary capture path, never EXPOSE. + const TrackPlan park = makeParkPlan("{A}"); + CHECK(!chainReadsParked(park.flags, FlagValues{1, 1, 1, 1})); // flags all fine +} + +// -- the EXPOSE console report ------------------------------------------------- + +static void testExposedTracksAreNamedAndEmptyListSaysNothing() { + CHECK(describeExposedTracks({}).empty()); + + const std::string one = describeExposedTracks({"Bass"}); + CHECK(one.find("1 track ") != std::string::npos); + CHECK(one.find("Bass") != std::string::npos); + + const std::string two = describeExposedTracks({"Bass", "Drum bus"}); + CHECK(two.find("2 tracks ") != std::string::npos); + CHECK(two.find("Bass") != std::string::npos); + CHECK(two.find("Drum bus") != std::string::npos); +} + +static void testExposedReportNeverUsesRefusalWording() { + // Daniel's ruling was EXPOSE, not refuse -- the wording must never suggest the + // tool declined to act. + const std::string msg = describeExposedTracks({"Bass"}); + CHECK(msg.find("refus") == std::string::npos); +} + +static void testShouldReportSkipsAnExactRepeatOnly() { + const std::vector a{"Bass"}; + const std::vector b{"Bass", "Drum bus"}; + + CHECK(!shouldReport(/*sameOwnerAsLast=*/true, a, a)); // exact repeat: silent + CHECK(shouldReport(/*sameOwnerAsLast=*/true, a, b)); // set changed: reprint + CHECK(shouldReport(/*sameOwnerAsLast=*/false, a, a)); // owner (project) changed: reprint + CHECK(shouldReport(/*sameOwnerAsLast=*/true, a, {})); // healed to none: reprint (resets memo) +} + +int main() { + testEachDrivenFlagAddressesItsOwnReaperParameter(); + testNoDrivenFlagCanEverAddressMuteOrSolo(); + testAParkPlanDrivesEveryDrivenFlagToZero(); + + testAPluginAddedWhileRestoredJoinsTheUnionRatherThanBeingDropped(); + testAPluginRemovedWhileRestoredStaysInTheUnionForResolveFxRestoreToDrop(); + + testAChainSittingAtEveryParkedValueReadsAsParkShaped(); + testOneFlagStillAtTheUsersValueMeansNotParkShaped(); + testAnEmptyPlanProvesNothing(); + testFxOfflineAloneIsNotFoldedIntoTheParkedRead(); + + testExposedTracksAreNamedAndEmptyListSaysNothing(); + testExposedReportNeverUsesRefusalWording(); + testShouldReportSkipsAnExactRepeatOnly(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_view_reconcile.cpp b/tests/test_view_reconcile.cpp new file mode 100644 index 0000000..dda1122 --- /dev/null +++ b/tests/test_view_reconcile.cpp @@ -0,0 +1,204 @@ +// Standalone tests for the Design View repair pass's pure decision — no REAPER, no +// test framework. +// +// The two properties the pass exists for: it drives a flag ONLY where a park record +// names the track (so a track the tool never parked can never have its main send +// re-enabled under it), and it fires on applied-output drift rather than on "an undo +// happened". Both AppliedStates are produced by the real fingerprint +// (view::appliedState) over real models, so a change to what the fingerprint carries +// shows up here rather than being papered over by hand-built fixtures. + +#include "../src/shell/view/view_reconcile.h" + +#include +#include +#include +#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 { + +TrackSnapshot captured(int showInTcp, int showInMixer, int mainSend, int fxEnable) { + TrackSnapshot snap; + snap.showInTcp = showInTcp; + snap.showInMixer = showInMixer; + snap.mainSend = mainSend; + snap.fxEnable = fxEnable; + return snap; +} + +bool hasOp(const std::vector& ops, const std::string& guid, Flag flag, int value) { + for (const TrackFlagOp& op : ops) + if (op.guid == guid && op.flag == flag && op.value == value) return true; + return false; +} + +bool namesTrack(const std::vector& ops, const std::string& guid) { + for (const TrackFlagOp& op : ops) + if (op.guid == guid) return true; + return false; +} + +} // namespace + +// -- the trigger: applied-output drift, not "an undo happened" ----------------- + +static void testAnUndoThatLeftOurAppliedOutputAloneRepairsNothing() { + // The rejected trigger is "did an undo occur" — an undo that touched nothing we + // drive still answers yes, and repairing on it rewrites state the user owns. + ViewModeModel model; + model.setActiveMode(kDesignModeId); + model.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + const view::AppliedState state = view::appliedState(model); + CHECK(planReconcile(state, state).empty()); +} + +static void testADriftedActiveModeAloneIsNotAReasonToDriveATrack() { + // Drift is the gate, not the instruction: the record still has an owner, so the + // reapply that follows this pass is what drives it. + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + ViewModeModel reloaded; // active mode Arrange — the fingerprint differs + reloaded.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + CHECK(view::appliedState(applied) != view::appliedState(reloaded)); + CHECK(planReconcile(view::appliedState(applied), view::appliedState(reloaded)).empty()); +} + +// -- the scope rule: a record names the track, or nothing is written ----------- + +static void testARecordTheReloadedModelLostIsHandedBackItsCapturedValues() { + // The undo case: REAPER rolled view_state back to before the switch, so the model + // no longer records the park — but the flags are still driven on the track, and + // this memo is the only surviving account of what they were. + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + const ViewModeModel reloaded; // Arrange, no records at all + + const std::vector ops = + planReconcile(view::appliedState(applied), view::appliedState(reloaded)); + + CHECK(ops.size() == 4); + CHECK(hasOp(ops, "{A}", Flag::ShowInTcp, 1)); + CHECK(hasOp(ops, "{A}", Flag::ShowInMixer, 1)); + CHECK(hasOp(ops, "{A}", Flag::MainSend, 1)); + CHECK(hasOp(ops, "{A}", Flag::FxEnable, 1)); +} + +static void testARepairedFlagReturnsToItsCapturedValueNeverToOn() { + // A track the user had OUT of the master mix and FX-bypassed before we parked it: + // the repair must hand back exactly that, not a hardcoded "on". + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 0, 0, 0)); + + const ViewModeModel reloaded; + + const std::vector ops = + planReconcile(view::appliedState(applied), view::appliedState(reloaded)); + + CHECK(hasOp(ops, "{A}", Flag::MainSend, 0)); + CHECK(hasOp(ops, "{A}", Flag::ShowInMixer, 0)); + CHECK(hasOp(ops, "{A}", Flag::FxEnable, 0)); + CHECK(!hasOp(ops, "{A}", Flag::MainSend, 1)); +} + +static void testATrackNeitherStateRecordsIsNeverDrivenAtAll() { + // The regression this rule is built against: a track the model merely calls + // active is NOT a track the tool parked. It holds no captured value, so asserting + // B_MAINSEND=1 on it would silently re-enable a send the user disabled by hand. + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + ViewModeModel reloaded; + reloaded.membership().tag("{B}", kArrangeModeId); // active, never parked, no record + + const std::vector ops = + planReconcile(view::appliedState(applied), view::appliedState(reloaded)); + + CHECK(namesTrack(ops, "{A}")); + CHECK(!namesTrack(ops, "{B}")); +} + +static void testASpentFlagHalfIsNotARecordToRepairFrom() { + // A restore already handed the flags back and zeroed them on the record; the FX + // half outlives that. Repairing from the spent half would drive those zeros — + // i.e. re-park the track — so the fingerprint must not carry it. + ViewModeModel applied; + applied.setActiveMode(kDesignModeId); + applied.storeSnapshot("{A}", captured(1, 1, 1, 1)); + applied.releaseSnapshotFlags("{A}"); + + const ViewModeModel reloaded; + + CHECK(planReconcile(view::appliedState(applied), view::appliedState(reloaded)).empty()); +} + +// -- the per-project memo: a tab switch must not erase another project's account -- + +static void testASecondProjectNotedInBetweenDoesNotEraseTheFirstProjectsMemo() { + // The bug this replaced: a single global slot meant noting project B's applied + // state overwrote project A's entirely, so a repair for A run afterward found no + // memo to compare against and silently skipped -- exactly what a tab-switch then + // an undo on the ORIGINAL project produces within one timer period. + ReaProject* const projA = reinterpret_cast(1); + ReaProject* const projB = reinterpret_cast(2); + + ViewModeModel appliedA; + appliedA.setActiveMode(kDesignModeId); + appliedA.storeSnapshot("{A}", captured(1, 1, 1, 1)); + + const ViewModeModel appliedB; // untouched — B has nothing driven + + std::map memo; + memo[projA] = view::appliedState(appliedA); + memo[projB] = view::appliedState(appliedB); // noted in between; must not clobber A + + const ViewModeModel reloadedA; // A's park record dropped, as an undo would leave it + + const ProjectReconcileResult result = + planReconcilePerProject(memo, projA, view::appliedState(reloadedA)); + + CHECK(result.ops.size() == 4); + CHECK(hasOp(result.ops, "{A}", Flag::MainSend, 1)); +} + +static void testAProjectWithNoPriorMemoGetsNoRepair() { + // "A project we hold no memo for gets no repair" -- the tool has no account of + // driving anything there, so it has no opinion (see the header's SCOPE note). + ReaProject* const projC = reinterpret_cast(3); + const std::map memo; // never noted + + const ViewModeModel reloaded; + const ProjectReconcileResult result = + planReconcilePerProject(memo, projC, view::appliedState(reloaded)); + + CHECK(result.ops.empty()); +} + +int main() { + testAnUndoThatLeftOurAppliedOutputAloneRepairsNothing(); + testADriftedActiveModeAloneIsNotAReasonToDriveATrack(); + + testARecordTheReloadedModelLostIsHandedBackItsCapturedValues(); + testARepairedFlagReturnsToItsCapturedValueNeverToOn(); + testATrackNeitherStateRecordsIsNeverDrivenAtAll(); + testASpentFlagHalfIsNotARecordToRepairFrom(); + + testASecondProjectNotedInBetweenDoesNotEraseTheFirstProjectsMemo(); + testAProjectWithNoPriorMemoGetsNoRepair(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +}