Merge branch 'omega-w3-t3-undo-collapse' into dev

This commit is contained in:
2026-08-06 09:49:22 -04:00
13 changed files with 513 additions and 802 deletions
+2
View File
@@ -392,6 +392,8 @@ cross-reference.
| **Ω-F5** | **The mode-switch symptom is both entry surfaces**, and it is *"nothing for 2 seconds, then everything switches"* — nothing renders until the whole synchronous body completes | Ω-W1-T1, Ω-W1-T2 | | **Ω-F5** | **The mode-switch symptom is both entry surfaces**, and it is *"nothing for 2 seconds, then everything switches"* — nothing renders until the whole synchronous body completes | Ω-W1-T1, Ω-W1-T2 |
| **Ω-F6** | **ACCEPTED, and it is a spec amendment: separate the FX parking's UI half from its processing half.** Visibility / routing / FX-enable apply **synchronously**; per-FX offline/online **defers to a later idle tick***"the switch doesn't happen during playback anyway… the UI is the priority when toggling modes"* | Ω-W1-T1 | | **Ω-F6** | **ACCEPTED, and it is a spec amendment: separate the FX parking's UI half from its processing half.** Visibility / routing / FX-enable apply **synchronously**; per-FX offline/online **defers to a later idle tick***"the switch doesn't happen during playback anyway… the UI is the priority when toggling modes"* | Ω-W1-T1 |
**SUPERSEDED — Ω-F6 and every deferred-park instruction under Ω-W1-T1 below (the new park-queue TU, its `OnTimer` drain, its `target_sources` line).** The deferral cost a SECOND undo point and could not be made to cost one: REAPER's undo surface has no append-to-point and no block held across a return to the message loop, so per-FX offline/online now runs inline in `applyMode`'s own block and the queue is deleted. The hitch is back on the switch's synchronous path, deliberately. The rest of Ω-W1-T1 (the honest undo mask, compare-before-write, `PreventUIRefresh`, the `resolve` hash map) stands as written. Two of that track's acceptance criteria below are superseded with it: "paints its new state before the park work runs" assumed the split that no longer exists — a switch now parks synchronously before anything paints — and "two rapid switches leave every track in the state the SECOND switch specifies" assumed a queue that could apply both — the synchronous latch REFUSES a switch fired while another is already applying, so a rapid second switch is dropped, not applied.
**Verified by Daniel, not a fork:** the deck knob-spacing defect is in the **Trigger** face. **Verified by Daniel, not a fork:** the deck knob-spacing defect is in the **Trigger** face.
**Gate is correct and must stay pixel-identical** — an acceptance criterion of Ω-W1-T4, not a **Gate is correct and must stay pixel-identical** — an acceptance criterion of Ω-W1-T4, not a
hope. hope.
+11 -10
View File
@@ -867,23 +867,24 @@ 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 the golden test literals pin would roll a format change and a codec extraction
together, which is the riskier order. together, which is the riskier order.
**Also over the bar, blocked differently.** `src/shell/view/view.cpp` measures **`src/shell/view/view.cpp` is no longer over the bar.** It measures **594 lines**
**625 lines** (re-measured after the deferred FX-park split took `fxGuidString`, (`wc -l`, re-measured after the undo collapse moved one track's whole park/restore —
`liveFxGuids` and the park/restore FX writes out into `view_fx_park`). Its remaining snapshot, flags and per-FX offline — into `view_fx_park`'s `parkTrack`/
seam is blocked not by a private-state/friend question but by a build file another `restoreTrack`). Recorded because the entry above used to name it as a second
team owns: `src/shell/view/` has no `CMakeLists.txt` of its own today — a new TU over-ceiling file: if it grows again, note that a further seam there is blocked
there costs one `target_sources` line in `src/app/CMakeLists.txt` instead. not by a private-state/friend question but by a build file another team owns —
`src/shell/view/` has no `CMakeLists.txt` of its own today, so a new TU costs one
`target_sources` line in `src/app/CMakeLists.txt` instead.
**Priority / risk.** Not stated. **Priority / risk.** Not stated.
**Done looks like.** `view_mode_model.cpp`'s JSON codec is extracted into its own **Done looks like.** `view_mode_model.cpp`'s JSON codec is extracted into its own
`view_state_codec` TU (with the friend/accessor question resolved deliberately, not `view_state_codec` TU (with the friend/accessor question resolved deliberately, not
sidestepped), dropping the file under the ~600-line ceiling; `view.cpp`'s own path is sidestepped), dropping the file under the ~600-line ceiling.
unblocked once the build-file ownership question is resolved.
## FX-GUID stability for `applyRestore` is unverified in the DAW ## FX-GUID stability for the park's restore is unverified in the DAW
**Context.** The Design View park/restore FX keying (`applyRestore`, **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_fx_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 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_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin
+8
View File
@@ -48,6 +48,14 @@ Checks for Θ, Ξ, Ψ, Ε, Ρ, Γ, and Ω work that no unit test can close. Buil
- [ ] Attempt a mode switch while the transport is playing, then while recording — both refuse, visibly (`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") - [ ] 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]` 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 <mode> 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]` **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 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]` 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) - [ ] `[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 ## Actions and drops
+7 -7
View File
@@ -62,13 +62,13 @@ target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model
# link graph free of the voice engine a link edge to it here means the design drifted. # 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}) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# The deferred FX-park queue's re-entrancy rule is pure (header-inline, no REAPER # The park surface's decisions are pure (header-inline, no REAPER types), so they are
# types), so it is CTest-covered like a core/ module. Declared here rather than in a # CTest-covered like a core/ module: the snapshot trust test, the refusal message and
# src/shell/view/CMakeLists.txt because that directory deliberately has none its # its once-per-changed-set gate, and the apply's undo-point fold. Declared here rather
# TUs are compiled into this target directly. view_mode_model is linked for the plan # than in a src/shell/view/CMakeLists.txt because that directory deliberately has none
# builders the pure half is a contract WITH makeRestorePlan's round trip through # its TUs are compiled into this target directly. view_mode_model is linked for
# the cancel path, and makeParkPlan's flag set behind the trust test; pinning either # makeParkPlan, the flag set the trust test folds over; pinning that against hand-built
# against hand-built ops would not catch a change to the other half. # ops would not catch a change to the planner.
reasampler_test(view_fx_park LINK fx_offline view_mode_model) reasampler_test(view_fx_park LINK fx_offline view_mode_model)
# Bank-package import: the promptless verb plus its action skin. Kept as its own # Bank-package import: the promptless verb plus its action skin. Kept as its own
+12 -15
View File
@@ -39,7 +39,6 @@
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown) #include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
#include "shell/persist/session.h" // ReaSamplerSession #include "shell/persist/session.h" // ReaSamplerSession
#include "shell/view/view.h" // reconcileManagedLanes / applyMode #include "shell/view/view.h" // reconcileManagedLanes / applyMode
#include "shell/view/view_fx_park.h" // the mode switch's deferred FX park
namespace capture = reasampler::capture; namespace capture = reasampler::capture;
@@ -181,7 +180,13 @@ static void OnTimer()
// the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live. // the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session); if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
g_session.poll(); // 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();
// persist stays MODEL-ONLY (loads the saved view model but does not apply // 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 // visibility, to avoid coupling persist to the view shell); poll() raises a
@@ -190,25 +195,17 @@ static void OnTimer()
// signal re-arms the bank panel's new-content detector — notified BEFORE the // 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 // 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). // pre-existing tracks can be mis-detected as "new" and mass-tagged).
if (g_session.consumeLoadSignal()) { // modeApplyInProgress is tested BEFORE the consume, not after the applyMode call:
// Every path that raises this signal — open, project switch, recycled // the load signal is one-shot, a re-entered applyMode fails closed, and a reapply
// pointer, undo/redo state restore — replaced the model the pending FX // dropped here is the saved mode never applied at all. Holding the whole block
// intents were planned against, so they are discarded rather than applied // back one tick also keeps the panel re-arm ahead of the reapply.
// to the project that replaced it. This is the queue's ONLY guard against if (!reasampler::modeApplyInProgress() && g_session.consumeLoadSignal()) {
// a recycled ReaProject*, which a pointer compare cannot see.
reasampler::discardDeferredFxParks();
reasampler::bankPanelNotifyProjectLoaded(); reasampler::bankPanelNotifyProjectLoaded();
// Reconcile lane ownership against the live project's lanes (P_LANENAME, // Reconcile lane ownership against the live project's lanes (P_LANENAME,
// the cross-session source of truth) BEFORE reapplying visibility. Never // the cross-session source of truth) BEFORE reapplying visibility. Never
// re-mints, never mass-tags. // re-mints, never mass-tags.
reasampler::reconcileManagedLanes(g_session.view(), nullptr); reasampler::reconcileManagedLanes(g_session.view(), nullptr);
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr); reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
} else {
// A mode switch applies its visibility/routing writes synchronously and
// leaves the per-FX offline work here, so the new mode paints before the
// plugins unload. Idle cost is one empty test. Skipped on a load tick so
// the reapply's own intents defer one tick like any other switch's.
reasampler::drainDeferredFxParks();
} }
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
@@ -26,7 +26,6 @@
#include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key #include "shell/capture/track_guid.h" // shared MediaTrack* -> canonical GUID key
#include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint #include "shell/panel/panel_window.h" // bankPanelInvalidate — footer toggle repaint
#include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell) #include "shell/view/view.h" // applyMode + mintManagedLanes (D2 shell)
#include "shell/view/view_fx_park.h" // drainDeferredFxParks — see persistViewState
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountSelectedTracks #define REAPERAPI_WANT_CountSelectedTracks
@@ -145,13 +144,6 @@ enum class PersistScope { Full, ViewOnly };
// the project is unsaved, prompts Save-As first (mirrors the flow capture uses) — // 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. // DAW-ONLY: Main_SaveProject(proj, true) blocks until the dialog is dismissed.
void persistViewState(PersistScope scope) { void persistViewState(PersistScope scope) {
// BEFORE the model is serialized, and before the Save-As below can write a
// .rpp: a deferred FX restore leaves the chain offline while the model has
// already dropped the snapshot that would replan it. Why that combination is
// unrecoverable on reopen is at drainDeferredFxParks. Runs for BOTH scopes —
// the narrowed save still writes the key the half-applied park contradicts.
drainDeferredFxParks();
if (!g_session->view().membership().empty()) { if (!g_session->view().membership().empty()) {
ReaProject* proj = EnumProjects(-1, nullptr, 0); ReaProject* proj = EnumProjects(-1, nullptr, 0);
if (proj) { if (proj) {
-2
View File
@@ -22,7 +22,6 @@
#include "shell/panel/panel_input.h" // bankPanelTailSetting #include "shell/panel/panel_input.h" // bankPanelTailSetting
#include "shell/persist/session.h" #include "shell/persist/session.h"
#include "shell/view/view.h" // applyMode / mintManagedLanes #include "shell/view/view.h" // applyMode / mintManagedLanes
#include "shell/view/view_fx_park.h" // drainDeferredFxParks
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTrackMediaItems #define REAPERAPI_WANT_CountTrackMediaItems
@@ -212,7 +211,6 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) {
// Persist outside the block. The offline render's own save gate already forced a // 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 // saved project, so the Save-As-guarded persist the Design View actions need
// cannot have anything to prompt for here. // cannot have anything to prompt for here.
drainDeferredFxParks(); // the reapply above may have deferred a restore — see the contract there
session.saveToActiveProject(); session.saveToActiveProject();
if (!placed) { if (!placed) {
+140 -62
View File
@@ -37,15 +37,15 @@ decide membership or mode rules.
The FX half asks "is ANY FX offline", not "every" — no read distinguishes a 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 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 — committing nothing false. An absent snapshot is NOT evidence of a clean chain —
`discardDeferredFxParks` drops intents whose flag writes already landed, so a a saved project, a hand-edited chain and an undo/redo can each present one; the
reloaded model and an already-parked project routinely coexist. A park meeting Gotchas below enumerate the routes that still reach it. A park meeting that pair
that pair REFUSES: no flag write, no FX-offline enqueue, no snapshot, and a 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 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 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 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 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 pre-park truth is gone. **The refusal's cost is deliberate and it is not
symmetric with "hidden forever":** because it skips the FX enqueue too, a track 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 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 good. Refusing guarantees nothing false is written; it does not promise to
finish the park. Decision at `view_fx_park`'s `decidePark`. finish the park. Decision at `view_fx_park`'s `decidePark`.
@@ -66,36 +66,51 @@ decide membership or mode rules.
active mode — stateful plugins (convolution, loaded samplers, tail-holding active mode — stateful plugins (convolution, loaded samplers, tail-holding
effects) re-initialize on return (load hitch, un-persisted internal state lost). effects) re-initialize on return (load hitch, un-persisted internal state lost).
Accepted cost of the CPU reclaim; surfaced at the toggle affordance (tooltip). Accepted cost of the CPU reclaim; surfaced at the toggle affordance (tooltip).
**The hitch no longer sits on the switch's synchronous path:** per-FX **The hitch sits on the switch's synchronous path, deliberately.** Per-FX
offline/online is enqueued and applied on a later idle tick (`view_fx_park`), offline/online runs inside `applyMode`'s own undo block, between the flag
so the new mode paints first. The deferral changes only WHEN the plugins move — writes and `Undo_EndBlock2`, so an action-driven switch does not return until
they still unload and re-instantiate, and un-persisted internal state is still every plugin has moved. That cost is the price of the invariant below, and it
lost. What it does change is the undo record, and REAPER's behaviour there is was accepted as such.
now OBSERVED, not assumed: the drain's `TrackFX_SetOffline` calls (the only - **ONE mode switch is ONE undo point.** Everything the switch wrote into the
`TrackFX_SetOffline` in the tree) run outside any undo block, and REAPER mints project — the driven flags, every per-FX offline/online, the lane state —
ONE implicit undo point per call — a small project measured 10 points for one rolls back in a single Ctrl-Z. (The `view_state` ext-state write is NOT among
switch, 1 for the switch itself and 9 for its FX. Ctrl-Z walks them one at a them; it lands after the block, see Gotchas.) What was MEASURED (before any
time, each step re-onlining a single FX, so one Ctrl-Z after a switch does not fix): a `TrackFX_SetOffline` made outside any undo block mints ONE implicit
bring the plugins back. It also puts the switch point BELOW every FX point: an undo point per call, and a small project showed 10 points for one switch — 1
undo deep enough to restore the driven flags and roll the `"reasampler"` ext for the switch itself and 9 for its FX. That measurement is the whole reason
state back has already re-onlined every FX, so flags, FX and snapshot move the FX writes must sit inside a block. Which block is the design decision, and
together. The per-FX fragmentation is itself a defect to be closed by collapsing it is settled: the SWITCH'S OWN. The undo surface
the drain's writes into one undo point; nothing here depends on them staying (`vendor/reaper-sdk/sdk/reaper_plugin_functions.h:7745-7822`) is
separate. **The idle tick is not the only drain point.** Any path that begin/end/state-change and nothing else — no append-to-point, no coalescing
serializes the view model drains synchronously first (`persistViewState`, flag, no undo-silent write — and a block cannot be held open across a return
`render_in_place`), because a save landing between a restore's synchronous flag to the message loop, so ANY deferral of the writes costs a second undo point.
writes and its drain would record offline FX beside a model that no longer Two points is a rejected design, not a lesser one: do not reintroduce a
carries the snapshot to replan them — unrecoverable on reopen. So an deferred queue, an idle drain, or a second block. That an explicit block
action-driven switch does pay the FX hitch before it returns; the repaint and suppresses the per-call implicit points is still the PREMISE the whole
the undo block have both closed by then, which is what the deferral was for. arrangement rests on — INFERENCE from the measurement above, not observed;
Any new caller that reapplies a mode and then persists inherits this obligation. `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 - **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 a apply mask (`kApplyUndoMask`) drops `UNDO_STATE_FX` and ORs it back in when
driven flag in that domain moved; the only such flag is `I_FXEN`, which every `I_FXEN` or a per-FX offline state actually moved — which every park does. So
park writes. So any switch that parks at least one track still makes REAPER any switch that parks at least one track still makes REAPER marshal the
marshal the project-wide FX chunk into its undo record. The saving is real only project-wide FX chunk into its undo record. The saving is real only on a
on a reapply and on a no-op switch. Narrowing it further would mean not carrying reapply that wrote nothing and on a no-op switch. Narrowing it further would
`I_FXEN` in the undo record at all, which would break "one switch is one Ctrl-Z." 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.
- **Show-both semantics:** a per-track "pin visible across modes" flag re-enables - **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 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 and is never parked — its driven flags stay at snapshot/restored values, FX
@@ -133,8 +148,8 @@ applies the resulting lane state to live tracks.
## Modules ## Modules
- `view` — Design View shell: snapshots flag values before parking, drives hide + CPU-park on inactive-mode leaves (`B_SHOWINTCP`/`B_SHOWINMIXER`/`B_MAINSEND`/`I_FXEN`, with per-FX offline deferred to `view_fx_park`), restores from snapshot. 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` — 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: the `TrackFX_GetFXGUID` identity read snapshot/park/restore share, the deferred intent queue that keeps `TrackFX_SetOffline` off the switch's synchronous path (at most one intent per track GUID, latest wins, an intent landing on its own pending inverse cancels it), the idle-tick drain `main.cpp`'s `OnTimer` calls, the per-track park 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, and `trackFlagParm` the ONE `Flag` → REAPER-parameter mapping snapshot's read, park's read and restore's write all address), and the refusal report (`reportRefusedParks`, which names the tracks and holds the last-reported set so a reapply does not reprint it). **The drain owns no model state.** A snapshot is dropped where the restore is PLANNED — the flags are back at their captured values from that moment, and a model that still described the track as parked would let a persist or a reapply inside the drain window replan a restore over whatever the user changed since. What the deferral costs instead is that the live FX chain stops being a trustworthy snapshot source while an intent is pending: a park that CANCELS a pending restore takes the pre-park FX states from that restore's ops (`preParkFxFromCancelledRestore`), because the chain still reads the parked values and the cancel means no drain will ever fix them. - `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_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`. - `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`.
## Gotchas ## Gotchas
@@ -142,6 +157,11 @@ applies the resulting lane state to live tracks.
- The pure mode model (`ViewModeModel`, membership, `reconcile(liveGuids)`, the - The pure mode model (`ViewModeModel`, membership, `reconcile(liveGuids)`, the
snapshot-based park/restore planner) lives in `core/view` — reference it, do not snapshot-based park/restore planner) lives in `core/view` — reference it, do not
duplicate its spec here. 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 - The Two-canvas sub-phase (Phase D2/E)'s settled DAW-application rules
(fixed-lane mechanics, mode-aware capture placement, hidden-AND-silenced) are (fixed-lane mechanics, mode-aware capture placement, hidden-AND-silenced) are
reflected in Invariants above; the membership/lane-ownership model concepts reflected in Invariants above; the membership/lane-ownership model concepts
@@ -152,38 +172,96 @@ applies the resulting lane state to live tracks.
under whatever mode id is active at that point, not the one the user undid back 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; to. Pre-existing: `snapshots_` already carries this same model-vs-undo split;
the solo cache inherits it rather than introducing it. Not fixed here. the solo cache inherits it rather than introducing it. Not fixed here.
- An undo/redo also DISCARDS every pending FX intent (`discardDeferredFxParks`), - **Which routes reach the no-snapshot-plus-parked-chain pair, re-derived for the
which is not the pure loss it reads as: the same tick reapplies the active mode one-point design.** Three of the routes that used to reach it were artifacts of
over the reloaded model, re-planning a park for every inactive leaf, so parked the retired deferral window, and are now NARROWED TO THE RE-ENTRANCY WINDOW
FX converge on the following drain. The one case that does not self-heal is a rather than gone: the flags-written-FX-not moment still exists inside
track whose reloaded model carries no snapshot — nothing plans its restore, so `parkTrack`, but nothing can observe it except code re-entered through the
FX left offline stay offline, and its next park REFUSES rather than pump premise below, and `applyMode` fails closed when re-entered
re-snapshotting the parked chain (snapshot-source invariant above). Stuck, but (`modeApplyInProgress`). The window that remains is what a call REACHABLE FROM
never falsely committed — and never finished either, since the refusal skips the a pumped message loop but not routed through `applyMode` could see — no save,
FX enqueue too. Full contract at `discardDeferredFxParks`. no reload and no discard reaches it (`main.cpp`'s `OnTimer` defers
- **Which routes actually reach that no-snapshot-plus-parked-chain pair.** An undo `session.poll()` itself, the one caller of `loadFromProject` a re-entered timer
of a mode switch (Ctrl-Z) is NOT one of them: per the observed undo semantics could reach, under this same latch), and there is no queued intent for an undo
above, the flags and the FX come back at the same step. Whether the ext-state to drop. What survives:
snapshot comes back with them is INFERENCE, not observed, and untested in the - A `view_state` that PARSED but carries no snapshot for the track — a snapshot
REDO direction: `[verify — DAW]` does a redo that re-parks a track's flags/FX `reconcile` pruned while its track was out of the live enumeration, then
also restore a `view_state` that is snapshot-free for it (matching a fresh undo/redo-restored with the track but not the snapshot. (Asserted by the
park), or can it re-park the live track while the model still holds a stale branch that introduced the refusal; not re-observed since.)
snapshot? What is confirmed to reach the pair: a `view_state` that PARSED but - A hand or script edit that leaves a chain park-shaped — including the single
carries no snapshot for the track (a snapshot `reconcile` pruned while its likeliest real case, a track with untouched flags and just ONE hand-offlined
track was out of the live enumeration, then undo/redo-restored with the track plugin (the FX half of the disjunction refuses alone; the flags never have to
but not the snapshot), and a hand or script edit that leaves a chain read parked too).
park-shaped — including the single likeliest real case, a track with untouched - A track the USER keeps hidden from both panels, out of the mix and
flags and just ONE hand-offlined plugin (the FX half of the disjunction FX-bypassed. It reads identically to both of the above and is refused too —
refuses alone; the flags never have to read parked too). A track the USER no lost state there at all, which is why the report asserts no cause.
keeps hidden from both panels, out of the mix and FX-bypassed reads - A REDO of a mode switch, hypothetically — the mechanism and its two
identically to all of them and is refused too — no lost state there at all, unverified links are in the `view_state` gotcha below, which owns that
which is why the report asserts no cause. question; do not re-derive it here.
A DIFFERENT strand entirely, which the refusal report does NOT cover: an
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
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
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
then rebuilds the model from that stale value, and the reapply it triggers
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.
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
absent or malformed `view_state` (`loadViewModel`, `ext_state_io.cpp`) is not 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 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, Arrange, empty membership), so nothing is planned to park the track,
`decidePark` never runs, and a track saved parked stays stuck with no console `decidePark` never runs, and a track saved parked stays stuck with no console
line whatsoever. 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.
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays - `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
attached to its plugin across a chain mutation while parked. That is 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_fx_park.cpp`) and SWS issue #802 is a
+96 -97
View File
@@ -1,7 +1,8 @@
// See view.h. Compiled into the reaper_reasampler module; includes // See view.h. Compiled into the reaper_reasampler module; includes
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). // reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
// Tree arithmetic lives in view_tree (pure); this file owns REAPER reads/writes // Tree arithmetic lives in view_tree (pure) and one track's park/restore in
// and the snapshot-before-park ordering. // view_fx_park; this file owns the plan iteration, lanes, parents and the one
// undo block they all ride.
#include "shell/view/view.h" #include "shell/view/view.h"
@@ -25,6 +26,7 @@
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks #define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetPlayStateEx #define REAPERAPI_WANT_GetPlayStateEx
#define REAPERAPI_WANT_GetTrack #define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_GetMediaTrackInfo_Value
@@ -36,6 +38,7 @@
#define REAPERAPI_WANT_TrackList_AdjustWindows #define REAPERAPI_WANT_TrackList_AdjustWindows
#define REAPERAPI_WANT_UpdateArrange #define REAPERAPI_WANT_UpdateArrange
#define REAPERAPI_WANT_UpdateTimeline #define REAPERAPI_WANT_UpdateTimeline
#define REAPERAPI_WANT_ValidatePtr2
// Lane minting: item-side lane reads/writes assigning each item to its mode's lane. // Lane minting: item-side lane reads/writes assigning each item to its mode's lane.
#define REAPERAPI_WANT_CountTrackMediaItems #define REAPERAPI_WANT_CountTrackMediaItems
#define REAPERAPI_WANT_GetTrackMediaItem #define REAPERAPI_WANT_GetTrackMediaItem
@@ -65,8 +68,8 @@ constexpr int kTransportMoving = 1 | 4;
// (UNDO_STATE_MISCCFG covers extension state). NOT UNDO_STATE_ALL, which // (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 // 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 // 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 an FX-domain // what the switch changed. FX is OR'd back in per apply, only when I_FXEN or a
// flag really moved. // per-FX offline state really moved (TrackApplyResult::fxMoved).
// [verify — DAW] INFERRED, not documented: UNDO_STATE_TRACKCFG reads // [verify — DAW] INFERRED, not documented: UNDO_STATE_TRACKCFG reads
// "track/master vol/pan/routing" (reaper_plugin.h:1541) and names neither // "track/master vol/pan/routing" (reaper_plugin.h:1541) and names neither
// B_SHOWINTCP/B_SHOWINMIXER nor the fixed-lane properties (assumed to ride // B_SHOWINTCP/B_SHOWINMIXER nor the fixed-lane properties (assumed to ride
@@ -84,6 +87,19 @@ struct UiRefreshHold {
UiRefreshHold& operator=(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.
bool g_applying = false;
struct ApplyLatch {
ApplyLatch() { g_applying = true; }
~ApplyLatch() { g_applying = false; }
ApplyLatch(const ApplyLatch&) = delete;
ApplyLatch& operator=(const ApplyLatch&) = delete;
};
// C_LANESCOLLAPSED=2: render a tool-split track like a normal single-lane // 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 // track showing only the playing lane (SDK: 1=collapsed, 2=hidden-lanes-exist
// but displays as non-fixed-lane). // but displays as non-fixed-lane).
@@ -146,50 +162,6 @@ MediaTrack* resolve(const TrackByGuid& byGuid, const std::string& guid) {
return it == byGuid.end() ? nullptr : it->second; // stale/deleted GUID — pruned by being skipped return it == byGuid.end() ? nullptr : it->second; // stale/deleted GUID — pruned by being skipped
} }
// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO;
// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract).
// The FX half is snapshotFxOffline's — only it knows when the live chain has
// stopped being a trustworthy source.
TrackSnapshot snapshotTrack(MediaTrack* tr, const std::vector<FxOfflineOp>& cancelledRestore) {
auto read = [tr](Flag f) {
return static_cast<int>(GetMediaTrackInfo_Value(tr, trackFlagParm(f)));
};
TrackSnapshot snap;
snap.showInTcp = read(Flag::ShowInTcp);
snap.showInMixer = read(Flag::ShowInMixer);
snap.mainSend = read(Flag::MainSend);
snap.fxEnable = read(Flag::FxEnable);
PreParkFx fx = snapshotFxOffline(tr, cancelledRestore);
snap.fxOffline = std::move(fx.states);
snap.fxKeying = fx.keying;
return snap;
}
// A reapply (tag/untag, project load) 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.
bool writeIfChanged(MediaTrack* tr, const char* parm, double value) {
if (GetMediaTrackInfo_Value(tr, parm) == value) return false;
SetMediaTrackInfo_Value(tr, parm, value);
return true;
}
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags, int& undoMask) {
for (const TrackFlagOp& op : flags) {
if (!writeIfChanged(tr, trackFlagParm(op.flag), static_cast<double>(op.value))) continue;
// 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) undoMask |= UNDO_STATE_FX;
}
}
// Managed-lane application: LanePlayOps are keyed by the lane's DURABLE name but
// C_LANEPLAYS:N by current ordinal, which renumbers on reorder — so every write
// re-resolves durable key -> ordinal first, and a lane whose name lacks the
// managed prefix never enters the map and so can never be driven.
// Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed / // Lane `laneIdx`'s durable name (P_LANENAME:n) on `tr`, or empty if unnamed /
// unavailable (non-fixed-lane track). // unavailable (non-fixed-lane track).
std::string laneName(MediaTrack* tr, int laneIdx) { std::string laneName(MediaTrack* tr, int laneIdx) {
@@ -216,25 +188,32 @@ std::map<std::string, int> managedLaneOrdinals(MediaTrack* tr) {
// item-side C_LANEPLAYS is read-only, so no per-item write exists or is // 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 // needed). B_FIXEDLANE_HIDDEN is also read-only — hide/show follows from
// C_LANEPLAYS=0/1, never written directly. // C_LANEPLAYS=0/1, never written directly.
void applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) { bool applyLanePlays(MediaTrack* tr, int laneIdx, int lanePlays) {
char parm[32]; char parm[32];
std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx); std::snprintf(parm, sizeof(parm), "C_LANEPLAYS:%d", laneIdx);
writeIfChanged(tr, parm, static_cast<double>(lanePlays)); return writeIfChanged(tr, parm, static_cast<double>(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 // 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), // 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 // enables fixed-lane mode on any track carrying a managed lane, and drives
// C_LANEPLAYS. UpdateTimeline() is the caller's job when this returns true // C_LANEPLAYS. UpdateTimeline() is the caller's job when `freeMode` comes back
// (SDK: required after an I_FREEMODE change). // true (SDK: required after an I_FREEMODE change).
bool applyLaneOps(const TrackByGuid& handleByGuid, LaneApplyResult applyLaneOps(const TrackByGuid& handleByGuid,
const std::vector<LanePlayOp>& lanes) { const std::vector<LanePlayOp>& lanes) {
if (lanes.empty()) return false; LaneApplyResult out;
if (lanes.empty()) return out;
std::map<std::string, std::vector<const LanePlayOp*>> byTrack; std::map<std::string, std::vector<const LanePlayOp*>> byTrack;
for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op); for (const LanePlayOp& op : lanes) byTrack[op.trackGuid].push_back(&op);
bool touchedFreeMode = false;
for (const auto& [guid, ops] : byTrack) { for (const auto& [guid, ops] : byTrack) {
MediaTrack* tr = resolve(handleByGuid, guid); MediaTrack* tr = resolve(handleByGuid, guid);
if (!tr) continue; // stale GUID — prune if (!tr) continue; // stale GUID — prune
@@ -248,22 +227,20 @@ bool applyLaneOps(const TrackByGuid& handleByGuid,
SetMediaTrackInfo_Value(tr, "I_FREEMODE", SetMediaTrackInfo_Value(tr, "I_FREEMODE",
static_cast<double>(kFreeModeFixedLanes)); static_cast<double>(kFreeModeFixedLanes));
applyTransparentLaneDisplay(tr); applyTransparentLaneDisplay(tr);
touchedFreeMode = true; out.freeMode = true;
out.wrote = true;
} }
const std::map<std::string, int> ordinals = managedLaneOrdinals(tr); const std::map<std::string, int> ordinals = managedLaneOrdinals(tr);
for (const LanePlayOp* op : ops) { for (const LanePlayOp* op : ops) {
auto it = ordinals.find(op->laneKey); auto it = ordinals.find(op->laneKey);
if (it == ordinals.end()) continue; // key not live on this track — prune if (it == ordinals.end()) continue; // key not live on this track — prune
applyLanePlays(tr, it->second, op->lanePlays); if (applyLanePlays(tr, it->second, op->lanePlays)) out.wrote = true;
} }
} }
return touchedFreeMode; return out;
} }
// Managed-lane minting: the DECISION (which tracks split, which lanes, which item
// goes where) is planLaneMinting's; this shell only reads, calls and applies.
// One pass, so the assign loop needs no per-item re-scan. // One pass, so the assign loop needs no per-item re-scan.
std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) { std::map<std::string, MediaItem*> itemHandlesByGuid(MediaTrack* tr) {
std::map<std::string, MediaItem*> byGuid; std::map<std::string, MediaItem*> byGuid;
@@ -294,6 +271,7 @@ std::vector<LaneTrack> readLaneTracks(
std::vector<LaneTrack> tracks; std::vector<LaneTrack> tracks;
tracks.reserve(handleByGuid.size()); tracks.reserve(handleByGuid.size());
for (const auto& [guid, tr] : handleByGuid) { for (const auto& [guid, tr] : handleByGuid) {
if (!tr) continue; // TrackHandles legally carries nulls post-validation (view.cpp)
LaneTrack lt; LaneTrack lt;
lt.trackGuid = guid; lt.trackGuid = guid;
@@ -397,11 +375,22 @@ bool transportBlocksModeSwitch(ReaProject* proj) {
return (GetPlayStateEx(proj) & kTransportMoving) != 0; return (GetPlayStateEx(proj) & kTransportMoving) != 0;
} }
bool modeApplyInProgress() { return g_applying; }
bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj) { 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
ApplyLatch latch;
if (!model.modes().contains(targetModeId)) { if (!model.modes().contains(targetModeId)) {
return false; // reject before touching the project — no partial apply return false; // 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.
ReaProject* const project = proj ? proj : EnumProjects(-1, nullptr, 0);
if (!ValidatePtr2(nullptr, project, "ReaProject*")) return false;
// The discriminator behind both halves of the contract in view.h. A gate placed // 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 // unconditionally here would break tagging and the project-load reapply during
// playback; a solo round on every reapply would flicker the user's solo on every // playback; a solo round on every reapply would flicker the user's solo on every
@@ -409,13 +398,13 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
const std::string outgoingModeId = model.activeModeId(); const std::string outgoingModeId = model.activeModeId();
const bool realSwitch = targetModeId != outgoingModeId; const bool realSwitch = targetModeId != outgoingModeId;
if (realSwitch && transportBlocksModeSwitch(proj)) { if (realSwitch && transportBlocksModeSwitch(project)) {
return false; // same fail-closed shape as the mode-exists guard above return false; // same fail-closed shape as the mode-exists guard above
} }
TrackHandles handleByGuid; TrackHandles handleByGuid;
std::vector<TrackFolderEntry> entries = readFolderEntries(proj, handleByGuid); std::vector<TrackFolderEntry> entries = readFolderEntries(project, handleByGuid);
const TrackByGuid trackByGuid = indexHandles(handleByGuid); TrackByGuid trackByGuid = indexHandles(handleByGuid);
FolderTree tree = buildFolderTree(entries); FolderTree tree = buildFolderTree(entries);
// Prune snapshots for tracks no longer in the live enumeration before // Prune snapshots for tracks no longer in the live enumeration before
@@ -442,9 +431,12 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
int undoMask = kApplyUndoMask; int undoMask = kApplyUndoMask;
bool laneModeChanged = false; bool laneModeChanged = false;
bool wrote = false; // anything at all this apply changed — see applyMintsUndoPoint
std::vector<std::string> refusedParkNames; std::vector<std::string> refusedParkNames;
FxRestoreDrops fxDrops;
int fxDropTracks = 0;
Undo_BeginBlock2(proj); Undo_BeginBlock2(project);
{ {
UiRefreshHold uiHold; // every write below lands with the TCP/MCP frozen UiRefreshHold uiHold; // every write below lands with the TCP/MCP frozen
@@ -455,48 +447,51 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
clearTrackSolos(handleByGuid, outgoingSolo); clearTrackSolos(handleByGuid, outgoingSolo);
} }
// PARK: snapshot before mutating. The per-FX offline half is deferred // PARK / RESTORE: view_fx_park owns one track's whole move, flags and
// (view_fx_park) — the expensive half, and the new mode's appearance // per-FX offline together. A refusal is named HERE, while the handle is
// does not wait on it. // fresh — never held past Undo_EndBlock2/TrackList_AdjustWindows.
for (const TrackPlan& tp : plan.park) { for (const TrackPlan& tp : plan.park) {
if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track if (tp.flags.empty()) continue; // every op in a TrackPlan targets one track
const std::string& guid = tp.flags.front().guid; const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(trackByGuid, guid); MediaTrack* tr = resolve(trackByGuid, guid);
if (!tr) continue; // stale GUID — prune // 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
// decidePark's contract; view_fx_park owns the reads. A refusal is named HERE, not held past Undo_EndBlock2. const TrackApplyResult r = parkTrack(model, guid, tr, tp.flags);
const ParkAction action = if (r.refused) { refusedParkNames.push_back(trackDisplayName(tr)); continue; }
decideParkForTrack(tr, model.snapshot(guid) != nullptr, tp.flags); if (r.wrote) wrote = true;
if (action == ParkAction::Refuse) { refusedParkNames.push_back(trackDisplayName(tr)); continue; } if (r.fxMoved) undoMask |= UNDO_STATE_FX;
// Enqueued FIRST: a park landing on this track's own pending restore
// cancels it, and those ops are then the only surviving record of the
// pre-park FX state.
const std::vector<FxOfflineOp> cancelled = deferFxPark(proj, guid);
if (action == ParkAction::SnapshotThenPark)
model.storeSnapshot(guid, snapshotTrack(tr, cancelled));
applyFlags(tr, tp.flags, undoMask);
} }
// RESTORE: flags verbatim now, per-FX offline on the drain.
for (const TrackPlan& tp : plan.restore) { for (const TrackPlan& tp : plan.restore) {
if (tp.flags.empty()) continue; if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid; const std::string& guid = tp.flags.front().guid;
MediaTrack* tr = resolve(trackByGuid, guid); MediaTrack* tr = resolve(trackByGuid, guid);
if (!tr) continue; // stale GUID — prune if (!tr || !trackStillLive(project, tr)) continue; // stale/deleted GUID — prune
applyFlags(tr, tp.flags, undoMask); const TrackApplyResult r = restoreTrack(model, guid, tr, tp.fxOffline, tp.flags);
deferFxRestore(proj, guid, tp.fxOffline); if (r.wrote) wrote = true;
// Consumed HERE, not on the drain: from this line the flags are back if (r.fxMoved) undoMask |= UNDO_STATE_FX;
// at their captured values, and a persist or reapply landing inside if (r.drops.total() > 0) { fxDrops.add(r.drops); ++fxDropTracks; }
// the drain window must not replan a restore over what the user has }
// changed since.
model.clearSnapshot(guid); // 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 // 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. // lane is silenced+hidden. Empty on a D1-only project — byte-identical there.
laneModeChanged = applyLaneOps(trackByGuid, plan.lanes); const LaneApplyResult lanes = applyLaneOps(trackByGuid, plan.lanes);
laneModeChanged = lanes.freeMode;
if (lanes.wrote) wrote = true;
// PARENT VISIBILITY (never parked): recomputed every toggle, never // PARENT VISIBILITY (never parked): recomputed every toggle, never
// snapshotted. Only the two visibility flags — never mainSend/FX on a parent. // snapshotted. Only the two visibility flags — never mainSend/FX on a parent.
@@ -506,8 +501,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
MediaTrack* tr = resolve(trackByGuid, node.guid); MediaTrack* tr = resolve(trackByGuid, node.guid);
if (!tr) continue; // stale GUID — prune if (!tr) continue; // stale GUID — prune
const double show = visible.count(node.guid) ? 1.0 : 0.0; const double show = visible.count(node.guid) ? 1.0 : 0.0;
writeIfChanged(tr, "B_SHOWINTCP", show); if (writeIfChanged(tr, "B_SHOWINTCP", show)) wrote = true;
writeIfChanged(tr, "B_SHOWINMIXER", show); if (writeIfChanged(tr, "B_SHOWINMIXER", show)) wrote = true;
} }
model.setActiveMode(targetModeId); model.setActiveMode(targetModeId);
@@ -535,11 +530,13 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
// fixed lanes this apply (SDK requirement for I_FREEMODE changes). // fixed lanes this apply (SDK requirement for I_FREEMODE changes).
if (laneModeChanged) UpdateTimeline(); if (laneModeChanged) UpdateTimeline();
Undo_EndBlock2(proj, undoLabel.c_str(), undoMask); const bool mint = applyMintsUndoPoint(realSwitch, wrote);
Undo_EndBlock2(project, mint ? undoLabel.c_str() : "", mint ? undoMask : 0);
// After the undo block and the UI hold: a refusal is a report, not a project // After the undo block and the UI hold: a report is not a project write, and
// write, and it must not join what a Ctrl-Z rolls back. // it must not join what a Ctrl-Z rolls back.
reportRefusedParks(proj, refusedParkNames); reportRefusedParks(project, refusedParkNames);
reportFxRestoreDrops(fxDrops, fxDropTracks);
return true; return true;
} }
@@ -556,6 +553,8 @@ bool mintManagedLanes(ViewModeModel& model, ReaProject* proj) {
const TrackByGuid trackByGuid = indexHandles(handleByGuid); 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); Undo_BeginBlock2(proj);
const bool changed = applyMintPlan(model, plan, trackByGuid); const bool changed = applyMintPlan(model, plan, trackByGuid);
+31 -5
View File
@@ -23,13 +23,39 @@ namespace reasampler {
// `nullptr` meaning the current project here is inferred by analogy, not confirmed. // `nullptr` meaning the current project here is inferred by analogy, not confirmed.
bool transportBlocksModeSwitch(ReaProject* proj); 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
// 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 // 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, // outgoing mode's solo state and replays the incoming mode's, runs planToggle,
// applies park/restore writes plus parent visibility flags, then sets the active // applies park/restore writes — flags AND per-FX offline, synchronously — plus
// mode. Wrapped in one Undo block. Returns false (no mutation) if `targetModeId` // parent visibility flags, then sets the active mode. ONE switch is ONE undo
// isn't registered, or if this is a real switch (target != active) while the // point; everything it moved rolls back in a single Ctrl-Z. Returns false (no
// transport is running. A reapply (target == active) is never gated and never // mutation) if `targetModeId` isn't registered, if this is a real switch
// touches solo. `proj` == nullptr means the current project. // (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); bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject* proj);
// Splits any track visible in more than one mode while carrying its own media // Splits any track visible in more than one mode while carrying its own media
+113 -143
View File
@@ -1,23 +1,19 @@
// See view_fx_park.h. Compiled into the reaper_reasampler module; includes // See view_fx_park.h. Compiled into the reaper_reasampler module; includes
// reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that). // reaper_plugin_functions.h without REAPERAPI_IMPLEMENT (main.cpp owns that).
// The queue's rule is pure (header, test_view_fx_park.cpp); this file owns the // The decisions are pure (header, test_view_fx_park.cpp); this file owns the
// REAPER reads/writes and the ordering against the live enumeration. // REAPER reads/writes and their ordering against the live enumeration.
#include "shell/view/view_fx_park.h" #include "shell/view/view_fx_park.h"
#include <cstring> // strnlen — bounded read of GetTrackName's buffer #include <cstring> // strnlen — bounded read of GetTrackName's buffer
#include <string> #include <string>
#include <unordered_map>
#include <vector> #include <vector>
#include "shell/capture/track_guid.h"
#define REAPERAPI_MINIMAL #define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_EnumProjects #define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetMediaTrackInfo_Value #define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetTrackName #define REAPERAPI_WANT_GetTrackName
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_ShowConsoleMsg #define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_TrackFX_GetCount #define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetFXGUID #define REAPERAPI_WANT_TrackFX_GetFXGUID
@@ -31,55 +27,24 @@ namespace reasampler {
namespace { namespace {
FxParkQueue g_queue;
ReaProject* g_owner = nullptr; // the project the pending intents were enqueued against
bool g_draining = false;
// The last refusal report, so an unchanged one is not reprinted on every reapply. // The last refusal report, so an unchanged one is not reprinted on every reapply.
ReaProject* g_reportedOwner = nullptr; ReaProject* g_reportedOwner = nullptr;
std::vector<std::string> g_reportedRefusals; std::vector<std::string> g_reportedRefusals;
// Makes "one drain at a time" explicit rather than implied by the call sites.
// RAII because an apply can throw and a stuck flag would silence the queue for
// the rest of the session.
struct DrainScope {
DrainScope() { g_draining = true; }
~DrainScope() { g_draining = false; }
DrainScope(const DrainScope&) = delete;
DrainScope& operator=(const DrainScope&) = delete;
};
ReaProject* currentProject() { return EnumProjects(-1, nullptr, 0); } ReaProject* currentProject() { return EnumProjects(-1, nullptr, 0); }
void adoptOwner(ReaProject* proj) { // Returns whether it actually wrote — the apply's undo point and its
ReaProject* p = proj ? proj : currentProject(); // UNDO_STATE_FX bit both hang off that verdict.
if (p == g_owner) return; //
g_queue.clear(); // intents planned against another project are never replayed here
g_owner = p;
}
// [verify — DAW] TrackFX_SetOffline unloads and re-instantiates the plugin, so // [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 // 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. // 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 // The compare is cheap and correct regardless; only the size of what it saves is
// unconfirmed. // unconfirmed.
void setOfflineIfChanged(MediaTrack* tr, int fx, bool offline) { bool setOfflineIfChanged(MediaTrack* tr, int fx, bool offline) {
if (TrackFX_GetOffline(tr, fx) == offline) return; if (TrackFX_GetOffline(tr, fx) == offline) return false;
TrackFX_SetOffline(tr, fx, offline); TrackFX_SetOffline(tr, fx, offline);
} return true;
void applyPark(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
for (int fx = 0; fx < fxCount; ++fx) setOfflineIfChanged(tr, fx, true);
}
// Restores per-FX offline from the plan verbatim — never a blanket "online".
// Which live FX each captured state belongs to is resolveFxRestore's call, and
// what it could not place comes back for the caller to report.
FxRestoreDrops applyRestore(MediaTrack* tr, const std::vector<FxOfflineOp>& ops) {
const FxRestoreResolution res = resolveFxRestore(ops, liveFxGuids(tr));
for (const FxOfflineWrite& w : res.writes) setOfflineIfChanged(tr, w.fxIndex, w.offline);
return res.drops;
} }
// The FX's own durable identity, braced exactly like the track GUID keys. Empty // The FX's own durable identity, braced exactly like the track GUID keys. Empty
@@ -104,6 +69,18 @@ std::string fxGuidString(MediaTrack* tr, int fx) {
return std::string(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<std::string> liveFxGuids(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
std::vector<std::string> guids;
guids.reserve(static_cast<std::size_t>(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, // ANY offline FX, not every — see the snapshot-source invariant (view_fx_park.h,
// src/shell/view/CLAUDE.md) for why. // src/shell/view/CLAUDE.md) for why.
bool anyFxOffline(MediaTrack* tr) { bool anyFxOffline(MediaTrack* tr) {
@@ -113,6 +90,41 @@ bool anyFxOffline(MediaTrack* tr) {
return false; 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<int>(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<std::string> 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<int>(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<TrackFlagOp>& flags, bool& fxMoved) {
bool wrote = false;
for (const TrackFlagOp& op : flags) {
if (!writeIfChanged(tr, trackFlagParm(op.flag), static_cast<double>(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 } // namespace
// GetTrackName, not P_NAME, matching capture::trackName's reason for the same // GetTrackName, not P_NAME, matching capture::trackName's reason for the same
@@ -144,114 +156,72 @@ void reportRefusedParks(ReaProject* proj, const std::vector<std::string>& refuse
const std::string msg = describeRefusedParks(refusedNames); const std::string msg = describeRefusedParks(refusedNames);
// "!SHOW:" so an unattended project-load reapply cannot force the console open // "!SHOW:" so an unattended project-load reapply cannot force the console open
// (reaper_plugin_functions.h:6536), same as the drain's drop report. // (reaper_plugin_functions.h:6536).
if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str()); if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str());
} }
std::vector<std::string> liveFxGuids(MediaTrack* tr) { 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<TrackFlagOp>& 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); const int fxCount = TrackFX_GetCount(tr);
std::vector<std::string> guids; for (int fx = 0; fx < fxCount; ++fx)
guids.reserve(static_cast<std::size_t>(fxCount)); if (setOfflineIfChanged(tr, fx, true)) out.fxMoved = out.wrote = true;
for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx)); return out;
return guids;
} }
PreParkFx snapshotFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& cancelled) { TrackApplyResult restoreTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr,
PreParkFx carried = preParkFxFromCancelledRestore(cancelled); const std::vector<FxOfflineOp>& fxOps,
if (!carried.states.empty()) return carried; const std::vector<TrackFlagOp>& flags) {
TrackApplyResult out;
// Nothing was cancelled, so the chain reads true. fxKeying stays Identity // Which live FX each captured state belongs to is resolveFxRestore's call
// a live capture always knows the chain. // the plan is written VERBATIM, never a blanket "online".
PreParkFx live; const FxRestoreResolution res = resolveFxRestore(fxOps, liveFxGuids(tr));
const std::vector<std::string> guids = liveFxGuids(tr); for (const FxOfflineWrite& w : res.writes)
live.states.reserve(guids.size()); if (setOfflineIfChanged(tr, w.fxIndex, w.offline)) out.fxMoved = out.wrote = true;
for (std::size_t fx = 0; fx < guids.size(); ++fx) out.drops = res.drops;
live.states.push_back(
FxOfflineState{guids[fx], TrackFX_GetOffline(tr, static_cast<int>(fx)) ? 1 : 0}); if (applyFlags(tr, flags, out.fxMoved)) out.wrote = true;
return live;
// 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;
} }
std::vector<FxOfflineOp> deferFxPark(ReaProject* proj, const std::string& guid) { void reportFxRestoreDrops(const FxRestoreDrops& drops, int trackCount) {
adoptOwner(proj); const std::string msg = describeFxRestoreDrops(drops, trackCount);
return g_queue.park(guid); // "!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.
void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector<FxOfflineOp> ops) { if (!msg.empty()) ShowConsoleMsg(("!SHOW:" + msg).c_str());
adoptOwner(proj);
g_queue.restore(guid, std::move(ops));
}
void discardDeferredFxParks() {
g_queue.clear();
g_owner = nullptr;
}
void drainDeferredFxParks() {
if (g_queue.empty()) return;
if (g_draining) return; // re-entered mid-apply: those intents are the outer drain's next pass
if (g_owner != currentProject()) {
// The project the intents were planned against was closed or switched
// away from: its tracks are not ours to write and its handles may be
// gone. Discard rather than apply. This catches only a DIFFERENT live
// pointer; a REAPER-recycled address is caught upstream, by the caller
// discarding on the session's own load transition.
discardDeferredFxParks();
return;
}
ReaProject* const proj = g_owner;
const DrainScope scope;
// Detached before the first write: [verify — DAW] applying is ASSUMED to pump
// the message loop (plugins load and unload), so a re-entrant switch can
// enqueue while this runs. Everything defensive below rests on that one
// assumption; each piece is correct regardless of whether it holds.
const std::vector<FxParkIntent> draining = g_queue.take();
std::unordered_map<std::string, MediaTrack*> byGuid;
const int count = CountTracks(proj);
byGuid.reserve(static_cast<std::size_t>(count));
for (int i = 0; i < count; ++i) {
MediaTrack* tr = GetTrack(proj, i);
if (!tr) continue;
std::string guid = guidString(tr);
if (!guid.empty()) byGuid.emplace(std::move(guid), tr);
}
FxRestoreDrops drops;
int dropTracks = 0;
for (const FxParkIntent& intent : draining) {
auto it = byGuid.find(intent.guid);
if (it == byGuid.end()) continue; // track deleted since the switch — prune
// Re-validated PER INTENT, not once above: under the same pumping
// assumption, a project closed or a track deleted between two applies
// leaves the handle resolved above dangling — a use-after-free, not a
// pruned intent. Same gate capture_realtime_shell's teardown uses; a null
// first argument validates the ReaProject* itself (SDK: proj is ignored
// when the pointer is a project).
if (!ValidatePtr2(nullptr, proj, "ReaProject*")) return;
if (!ValidatePtr2(proj, it->second, "MediaTrack*")) continue;
if (intent.park) {
applyPark(it->second);
continue;
}
const FxRestoreDrops d = applyRestore(it->second, intent.restoreOps);
if (d.total() > 0) {
drops.add(d);
++dropTracks;
}
}
// Captured FX state that could not be applied is REPORTED — silence would read
// to the user as "restore worked" while an FX sat at whatever state the park
// left it in. The "!SHOW:" prefix (reaper_plugin_functions.h:6536) keeps it from
// force-opening the console: this drain also runs behind an unattended
// project-load reapply, and cannot tell that case from an interactive toggle.
const std::string fxDropMsg = describeFxRestoreDrops(drops, dropTracks);
if (!fxDropMsg.empty()) ShowConsoleMsg(("!SHOW:" + fxDropMsg).c_str());
} }
} // namespace reasampler } // namespace reasampler
+62 -172
View File
@@ -1,11 +1,10 @@
#pragma once #pragma once
// Design View's park surface: the FX-identity read that snapshot, park and // Design View's park surface: one track parked or restored — the trust test,
// restore all address FX through, the deferred intent queue that keeps // the snapshot lifecycle, and the flag + per-FX writes both halves address
// TrackFX_SetOffline off the mode switch's synchronous path, the idle-tick drain // through this one file. Every write declared here runs synchronously inside
// that applies it, and the trust test deciding whether a chain may be // applyMode's own undo block, which is what makes one switch one undo point —
// snapshotted at all. See src/shell/view/CLAUDE.md's FX-parking caveat. // see src/shell/view/CLAUDE.md's FX-parking caveat.
#include <cstddef>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -18,108 +17,19 @@ class ReaProject;
namespace reasampler { namespace reasampler {
// 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<std::string> liveFxGuids(MediaTrack* tr);
// One deferred per-FX intent for one track. A park carries no ops (every live
// slot goes offline); a restore carries the planned ops verbatim.
struct FxParkIntent {
std::string guid;
bool park = false;
std::vector<FxOfflineOp> restoreOps;
};
// The queue's re-entrancy rule, pure so it can be asserted without a DAW: at
// most ONE intent per track GUID, and the latest one wins. Park and restore are
// inverses, so an intent landing on its own pending inverse CANCELS it rather
// than stacking — the queued work never ran, so the track already holds the
// state the newcomer asks for, and replaying both would be both slower and
// observably wrong.
class FxParkQueue {
public:
// Returns the ops of a pending restore this park CANCELLED, empty otherwise.
// The caller needs them: that restore never ran, so the live chain still
// reads the PARKED offline states and is no longer a source for a fresh
// pre-park snapshot (see preParkFxFromCancelledRestore).
//
// The empty return is a LOAD-BEARING sentinel that deliberately conflates
// "nothing was cancelled" with "cancelled a restore carrying no ops": a
// zero-op restore held no FX state to hand back, so falling through to a
// live chain read is the same answer, not a worse one.
std::vector<FxOfflineOp> park(const std::string& guid) {
FxParkIntent* held = find(guid);
if (!held) {
pending_.push_back(FxParkIntent{guid, true, {}});
return {};
}
if (held->park) return {};
std::vector<FxOfflineOp> cancelled = std::move(held->restoreOps);
erase(held);
return cancelled;
}
// A restore cancelling a pending park is COMPLETE at that point — the park
// never ran, so no drain will ever come for this GUID.
void restore(const std::string& guid, std::vector<FxOfflineOp> ops) {
FxParkIntent* held = find(guid);
if (!held) {
pending_.push_back(FxParkIntent{guid, false, std::move(ops)});
} else if (held->park) {
erase(held);
} else {
held->restoreOps = std::move(ops);
}
}
// Enqueue order, which is apply order: park before restore within one
// switch, as the synchronous body already orders them.
const std::vector<FxParkIntent>& pending() const { return pending_; }
bool empty() const { return pending_.empty(); }
void clear() { pending_.clear(); }
// Detaches everything pending, leaving the queue able to accept intents
// enqueued WHILE the caller applies what it took. [verify — DAW] applying
// loads/unloads plugins, which is ASSUMED to pump the message loop, so a
// re-entrant switch can enqueue mid-apply: iterating the live queue would
// dangle on the push_back, and clearing it afterwards would discard
// whatever arrived during the apply. The detach is correct either way; only
// the need for it is unconfirmed.
std::vector<FxParkIntent> take() {
std::vector<FxParkIntent> taken;
taken.swap(pending_);
return taken;
}
private:
FxParkIntent* find(const std::string& guid) {
for (FxParkIntent& i : pending_)
if (i.guid == guid) return &i;
return nullptr;
}
void erase(FxParkIntent* held) {
pending_.erase(pending_.begin() + (held - pending_.data()));
}
std::vector<FxParkIntent> pending_;
};
// -- may this chain be snapshotted? ------------------------------------------- // -- may this chain be snapshotted? -------------------------------------------
// //
// A pre-park snapshot may only ever be taken from a chain NO park has touched. // 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 // 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 // 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 // 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: // and silent. A model carrying no snapshot is NOT evidence the chain is clean;
// discardDeferredFxParks drops intents whose flag writes already landed, so a // src/shell/view/CLAUDE.md lists the routes that still reach that pair.
// reloaded model and an already-parked project routinely coexist.
// //
// A snapshot has TWO halves and the test covers both, disjunctively: the flags // 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 // can read clean while the FX are still offline (a hand recovery that stopped at
// the flags, a reload) and vice versa. Either half reading parked poisons the // the flags, one hand-offlined plugin) and vice versa. Either half reading
// whole snapshot, so either half alone refuses. // parked poisons the whole snapshot, so either half alone refuses.
// The REAPER track parameter each driven flag addresses. One home for the // 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 // mapping — park's live read, restore's write and the trust test below must
@@ -159,11 +69,10 @@ enum class ParkAction {
Refuse, // no snapshot AND a parked chain — the truth is gone Refuse, // no snapshot AND a parked chain — the truth is gone
}; };
// Refusing leaves the track exactly as found: no flag write, no FX-offline // Refusing leaves the track exactly as found: no flag write, no FX write, no
// enqueue, no snapshot. Nothing false is committed, so the moment the user's own // snapshot. Nothing false is committed, so the moment the user's own edit puts
// edit puts the chain back somewhere trustworthy the next park captures it // the chain back somewhere trustworthy the next park captures it correctly —
// correctly — where a snapshot of the parked state would have closed that door // where a snapshot of the parked state would have closed that door for good.
// for good.
inline ParkAction decidePark(bool haveSnapshot, bool chainReadsParked) { inline ParkAction decidePark(bool haveSnapshot, bool chainReadsParked) {
if (haveSnapshot) return ParkAction::ParkOnly; if (haveSnapshot) return ParkAction::ParkOnly;
return chainReadsParked ? ParkAction::Refuse : ParkAction::SnapshotThenPark; return chainReadsParked ? ParkAction::Refuse : ParkAction::SnapshotThenPark;
@@ -219,77 +128,58 @@ inline bool shouldReport(bool sameOwnerAsLast, const std::vector<std::string>& l
// better the second time. Gated by shouldReport. // better the second time. Gated by shouldReport.
void reportRefusedParks(ReaProject* proj, const std::vector<std::string>& refusedNames); void reportRefusedParks(ReaProject* proj, const std::vector<std::string>& refusedNames);
// The FX half of a snapshot, with the keying it must be read back under. // -- parking and restoring one track ------------------------------------------
struct PreParkFx { //
std::vector<FxOfflineState> states; // Both run INSIDE applyMode's undo block, on the switch's own synchronous path,
FxKeying keying = FxKeying::Identity; // 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;
}; };
// The FX half a fresh pre-park snapshot must carry when the park CANCELLED a // Parks one inactive leaf: the trust decision (decidePark), the pre-park
// pending restore: those ops are the only surviving record of the pre-park // snapshot when it is owed, then flags before FX so the track is out of the mix
// state, because the chain still reads the parked values until that restore // and bypassed before its plugins start unloading. On `refused` nothing at all
// drains — and it never will, the cancel dropped it. Empty in (nothing was // was written — the caller names the track (trackDisplayName) while its handle
// cancelled) means the caller reads the live chain instead. The restore's own // is still fresh.
// keying travels with it so a slot-keyed snapshot lifted from a legacy TrackApplyResult parkTrack(ViewModeModel& model, const std::string& guid, MediaTrack* tr,
// view_state does not silently become an identity-keyed one with no identities. const std::vector<TrackFlagOp>& flags);
//
// FAITHFUL TO THE SNAPSHOT, NOT THE CHAIN. The ops describe the chain as it was
// at the ORIGINAL park; applyPark enumerates it again at drain time. So an FX
// added while the track was parked (a floating FX-chain window, ReaScript) is
// absent from this reconstruction yet IS offlined by the cancelling park's
// drain — and so never comes back online. Rare, and recoverable by hand in the
// FX chain, but specific to the deferral.
//
// `offline` is already boolean by the time it arrives: makeRestorePlan narrowed
// FxOfflineState's defensive int to FxOfflineOp's bool, so this widening back to
// int restores the type, not lost information.
inline PreParkFx preParkFxFromCancelledRestore(const std::vector<FxOfflineOp>& cancelled) {
PreParkFx out;
if (cancelled.empty()) return out;
out.keying = cancelled.front().keying;
out.states.reserve(cancelled.size());
for (const FxOfflineOp& op : cancelled)
out.states.push_back(FxOfflineState{op.fxGuid, op.offline ? 1 : 0});
return out;
}
// The FX half of a fresh pre-park snapshot for `tr`: whatever the accompanying // Restores one returning leaf: FX before flags, park's order mirrored, so the
// park cancelled, else a live read of the chain. // chain comes back online while still bypassed and out of the mix and the track
PreParkFx snapshotFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& cancelled); // 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<FxOfflineOp>& fxOps,
const std::vector<TrackFlagOp>& flags);
// Enqueue against `proj` (nullptr = current project). An enqueue naming a // One console line for a whole apply's drops. Called after the undo block
// different project than the pending intents discards those unapplied. The park // closes: a report is not a project write and must not join what a Ctrl-Z rolls
// returns whatever pending restore it cancelled, per FxParkQueue::park. // back.
std::vector<FxOfflineOp> deferFxPark(ReaProject* proj, const std::string& guid); void reportFxRestoreDrops(const FxRestoreDrops& drops, int trackCount);
void deferFxRestore(ReaProject* proj, const std::string& guid, std::vector<FxOfflineOp> ops);
// Applies every pending intent. Touches NO model state — a restore's snapshot is
// dropped where the restore is planned (applyMode). Discards the queue unapplied
// if the project it was enqueued against is no longer current (close / switch);
// an intent whose track is gone is pruned. Idle cost is one empty-queue test.
// Re-entrant calls early-out: one drain at a time, the outer one owns the queue.
//
// Called on the idle tick AND synchronously before the view model is serialized
// (persistViewState, render_in_place). The second call is not an optimization:
// between a restore's synchronous flag writes and its drain the FX are still
// offline while the model has already dropped the snapshot that would replan
// them, so a save inside that window — deterministic under a custom action chain
// like "toggle mode; save project" — records offline FX beside a snapshot-free
// model, and nothing on reopen brings them back online. The cost is that an
// action-driven switch pays the FX hitch before it returns; its repaint and undo
// block have both closed by then, which is what the deferral was for.
void drainDeferredFxParks();
// Drops every pending intent without applying it. Called when the model the
// intents were planned against has been replaced (project load/switch, undo/redo
// state restore) — applying them then would write the pre-reload plan over the
// project that replaced it.
//
// Not pure loss: the caller reapplies the active mode over the reloaded model
// immediately after, which re-plans a park for every inactive leaf, so parked FX
// converge on the following drain. The case that does NOT self-heal is a track
// whose reloaded model carries no snapshot — nothing plans a restore for it, so
// FX left offline stay offline.
void discardDeferredFxParks();
} // namespace reasampler } // namespace reasampler
+30 -280
View File
@@ -1,15 +1,15 @@
// Standalone tests for the deferred FX-park queue's re-entrancy rule and the // Standalone tests for the Design View park surface's pure decisions — no
// snapshot-lifecycle contract that rides on it — no REAPER, no test framework. // REAPER, no test framework.
// //
// The properties under test: a mode switch leaves its per-FX offline work here, // The properties under test: a pre-park snapshot is never taken from a chain a
// so a second switch arriving before the first drained must leave every track in // park has already touched, the refusal that follows names its tracks and is
// the state the SECOND switch specifies — never the first's, never both replayed; // reported once per changed set, and one mode apply leaves at most ONE undo
// a cancel must not strand the pre-park FX state it was the last record of; and // point — none at all when a reapply found everything already where it wanted it.
// a pre-park snapshot is never taken from a chain a park has already touched.
#include "../src/shell/view/view_fx_park.h" #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" // makeRestorePlan — the ops' only producer #include "core/view/view_mode_model.h" // makeParkPlan — the park flags' only producer
#include <cstdio> #include <cstdio>
#include <string> #include <string>
@@ -21,219 +21,6 @@ static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \ #define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// -- helpers -----------------------------------------------------------------
// The ops applyMode hands a restore: one per FX captured in the track's snapshot.
static std::vector<FxOfflineOp> ops(const std::string& fxGuid, bool offline) {
return {FxOfflineOp{"{TRACK}", FxKeying::Identity, fxGuid, 0, offline}};
}
static const FxParkIntent* intentFor(const FxParkQueue& q, const std::string& guid) {
for (const FxParkIntent& i : q.pending())
if (i.guid == guid) return &i;
return nullptr;
}
// -- tests -------------------------------------------------------------------
static void testParkEnqueuesOneIntentCarryingNoOps() {
FxParkQueue q;
q.park("{A}");
CHECK(q.pending().size() == 1);
const FxParkIntent* held = intentFor(q, "{A}");
CHECK(held != nullptr);
CHECK(held && held->park);
CHECK(held && held->restoreOps.empty());
}
static void testParkReportsNothingCancelledWhenNoIntentWasPending() {
FxParkQueue q;
CHECK(q.park("{A}").empty());
}
static void testParkOnItsOwnPendingParkReportsNothingCancelled() {
FxParkQueue q;
q.park("{A}");
CHECK(q.park("{A}").empty());
}
static void testRestoreOnAnUndrainedParkCancelsRatherThanStacks() {
// The park never ran, so the track's FX still hold their captured state —
// exactly what the restore would write. Replaying both would unload every
// plugin only to reload it.
FxParkQueue q;
q.park("{A}");
q.restore("{A}", ops("{FX}", false));
CHECK(q.empty());
}
static void testParkOnAnUndrainedRestoreCancelsRatherThanStacks() {
// The mirror case: the restore never ran, so the FX are still parked offline,
// which is where the new park wants them.
FxParkQueue q;
q.restore("{A}", ops("{FX}", false));
q.park("{A}");
CHECK(q.empty());
}
static void testRepeatedParkStaysOneIntent() {
FxParkQueue q;
q.park("{A}");
q.park("{A}");
q.park("{A}");
CHECK(q.pending().size() == 1);
CHECK(intentFor(q, "{A}") && intentFor(q, "{A}")->park);
}
static void testLaterRestoreReplacesTheEarlierOnesOps() {
FxParkQueue q;
q.restore("{A}", ops("{OLD}", false));
q.restore("{A}", ops("{NEW}", true));
CHECK(q.pending().size() == 1);
const FxParkIntent* held = intentFor(q, "{A}");
CHECK(held && !held->park);
CHECK(held && held->restoreOps.size() == 1);
CHECK(held && held->restoreOps.front().fxGuid == "{NEW}");
CHECK(held && held->restoreOps.front().offline);
}
static void testOneTracksCancelLeavesEveryOtherTrackAlone() {
FxParkQueue q;
q.park("{A}");
q.park("{B}");
q.park("{C}");
q.restore("{B}", ops("{FX}", false)); // cancels B only
CHECK(q.pending().size() == 2);
CHECK(intentFor(q, "{A}") != nullptr);
CHECK(intentFor(q, "{B}") == nullptr);
CHECK(intentFor(q, "{C}") != nullptr);
// Order survives the middle erase: the drain applies in enqueue order.
CHECK(q.pending()[0].guid == "{A}");
CHECK(q.pending()[1].guid == "{C}");
}
static void testCancelledTrackCanBeQueuedAgain() {
// Two rapid switches then a third: the third is the one that must land.
FxParkQueue q;
q.park("{A}");
q.restore("{A}", ops("{FX}", false));
q.park("{A}");
CHECK(q.pending().size() == 1);
CHECK(intentFor(q, "{A}") && intentFor(q, "{A}")->park);
}
static void testClearDropsEverythingPending() {
FxParkQueue q;
q.park("{A}");
q.restore("{B}", ops("{FX}", true));
q.clear();
CHECK(q.empty());
CHECK(q.pending().empty());
}
// -- snapshot lifecycle ------------------------------------------------------
static void testParkHandsBackTheOpsOfTheRestoreItCancelled() {
// The cancelled restore is the LAST record of the pre-park FX state: the
// track's chain still reads the parked values (the restore never ran), and
// the cancel means no drain will ever put them back. A park that drops these
// snapshots the park's own offline zeros as if they were the user's state.
FxParkQueue q;
q.restore("{A}", ops("{FX}", false));
const std::vector<FxOfflineOp> cancelled = q.park("{A}");
CHECK(cancelled.size() == 1);
CHECK(cancelled.size() == 1 && cancelled.front().fxGuid == "{FX}");
CHECK(cancelled.size() == 1 && !cancelled.front().offline);
CHECK(q.empty()); // annihilated: the chain already holds what the park wants
}
static void testCancelledRestoreOpsBecomeTheFreshSnapshotsFxHalf() {
std::vector<FxOfflineOp> cancelled = ops("{ONE}", true);
cancelled.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, "{TWO}", 1, false});
const PreParkFx fx = preParkFxFromCancelledRestore(cancelled);
CHECK(fx.keying == FxKeying::Identity);
CHECK(fx.states.size() == 2);
CHECK(fx.states.size() == 2 && fx.states[0].fxGuid == "{ONE}" && fx.states[0].offline == 1);
CHECK(fx.states.size() == 2 && fx.states[1].fxGuid == "{TWO}" && fx.states[1].offline == 0);
}
static void testNothingCancelledLeavesTheFxHalfToTheCaller() {
const PreParkFx fx = preParkFxFromCancelledRestore({});
CHECK(fx.states.empty()); // caller reads the live chain instead
CHECK(fx.keying == FxKeying::Identity);
}
static void testSlotKeyedRestoreDoesNotBecomeIdentityKeyedWithNoIdentities() {
// A snapshot lifted from a pre-identity view_state is slot-keyed and carries
// no fxGuid. Re-labelling it Identity would make resolveFxRestore drop every
// entry as unidentified instead of writing it by slot.
std::vector<FxOfflineOp> cancelled = {
FxOfflineOp{"{TRACK}", FxKeying::Slot, "", 0, true},
FxOfflineOp{"{TRACK}", FxKeying::Slot, "", 1, false},
};
const PreParkFx fx = preParkFxFromCancelledRestore(cancelled);
CHECK(fx.keying == FxKeying::Slot);
CHECK(fx.states.size() == 2);
CHECK(fx.states.size() == 2 && fx.states[0].offline == 1 && fx.states[1].offline == 0);
}
// -- the makeRestorePlan <-> preParkFxFromCancelledRestore round trip ---------
//
// The cancel path's whole premise is that a planned restore's ops are a LOSSLESS
// carrier of the snapshot's FX half. makeRestorePlan is their only producer, so
// the real claim is that the pair composes to the identity on (fxOffline,
// fxKeying). Asserting it against hand-built ops would let a change to
// makeRestorePlan's field mapping or op ordering pass with every test green.
static void testRestorePlanOpsRebuildTheIdentityKeyedSnapshotVerbatim() {
TrackSnapshot snap;
snap.fxKeying = FxKeying::Identity;
snap.fxOffline = {FxOfflineState{"{ONE}", 1}, FxOfflineState{"{TWO}", 0},
FxOfflineState{"{THREE}", 1}};
const TrackPlan plan = makeRestorePlan("{TRACK}", snap);
const PreParkFx rebuilt = preParkFxFromCancelledRestore(plan.fxOffline);
CHECK(rebuilt.keying == FxKeying::Identity);
// Three DISTINCT entries, compared as a sequence: a dropped fxGuid, a flipped
// offline, or a reordering each fail here.
CHECK(rebuilt.states == snap.fxOffline);
}
static void testRestorePlanOpsRebuildTheSlotKeyedSnapshotVerbatim() {
TrackSnapshot snap;
snap.fxKeying = FxKeying::Slot;
snap.fxOffline = {FxOfflineState{"", 0}, FxOfflineState{"", 1}, FxOfflineState{"", 1}};
const TrackPlan plan = makeRestorePlan("{TRACK}", snap);
// Slot keying addresses by POSITION, so the identity holds only while the op
// at index i carries slot i.
CHECK(plan.fxOffline.size() == 3);
CHECK(plan.fxOffline.size() == 3 && plan.fxOffline[0].slot == 0 &&
plan.fxOffline[1].slot == 1 && plan.fxOffline[2].slot == 2);
const PreParkFx rebuilt = preParkFxFromCancelledRestore(plan.fxOffline);
CHECK(rebuilt.keying == FxKeying::Slot);
CHECK(rebuilt.states == snap.fxOffline);
}
// -- may this chain be snapshotted? ------------------------------------------- // -- may this chain be snapshotted? -------------------------------------------
// //
// Rationale: the snapshot-source invariant (view_fx_park.h, this directory's // Rationale: the snapshot-source invariant (view_fx_park.h, this directory's
@@ -359,70 +146,33 @@ static void testAFreshEmptySetReportsAndResetsTheMemo() {
CHECK(!shouldReport(/*sameOwnerAsLast=*/true, {}, {})); CHECK(!shouldReport(/*sameOwnerAsLast=*/true, {}, {}));
} }
// -- re-entrancy ------------------------------------------------------------- // -- 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 testTakeDetachesEverythingAndLeavesTheQueueEmpty() { static void testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite() {
FxParkQueue q; // An explicitly fired action stays undoable even when the plan found nothing
q.park("{A}"); // to write — the discard form is for reapplies only.
q.restore("{B}", ops("{FX}", true)); CHECK(applyMintsUndoPoint(/*realSwitch=*/true, /*wroteAnything=*/false));
CHECK(applyMintsUndoPoint(/*realSwitch=*/true, /*wroteAnything=*/true));
const std::vector<FxParkIntent> taken = q.take();
CHECK(taken.size() == 2);
CHECK(taken.size() == 2 && taken[0].guid == "{A}" && taken[0].park);
CHECK(taken.size() == 2 && taken[1].guid == "{B}" && !taken[1].park);
CHECK(q.empty());
} }
static void testIntentsArrivingDuringADrainSurviveIt() { static void testAReapplyThatWroteSomethingLeavesAPoint() {
// [verify — DAW] applying an intent loads/unloads plugins, which is ASSUMED to // A tag/untag reapply that actually parked a track: real project state moved.
// pump the message loop, so a switch can re-enter and enqueue mid-drain. Those CHECK(applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/true));
// intents belong to the NEXT drain — the one in progress must neither see them
// nor discard them.
FxParkQueue q;
q.park("{A}");
const std::vector<FxParkIntent> draining = q.take();
q.restore("{B}", ops("{FX}", false)); // arrives while {A} is being applied
CHECK(draining.size() == 1);
CHECK(draining.size() == 1 && draining.front().guid == "{A}");
CHECK(q.pending().size() == 1);
CHECK(intentFor(q, "{B}") != nullptr);
} }
static void testAReEntrantParkCancelsOnlyWhatIsStillPending() { static void testAReapplyThatWroteNothingLeavesNoPointAtAll() {
// {A}'s restore was already taken for the in-flight drain, so a park arriving // The project-load reapply over a project saved fully parked: every flag and
// mid-drain has nothing to cancel — it must queue as a fresh park rather than // every FX already sit where the plan wants them. Opening a project must not
// silently annihilate against an intent that has already been applied. // cost the user an undo step.
FxParkQueue q; CHECK(!applyMintsUndoPoint(/*realSwitch=*/false, /*wroteAnything=*/false));
q.restore("{A}", ops("{FX}", false));
q.take();
const std::vector<FxOfflineOp> cancelled = q.park("{A}");
CHECK(cancelled.empty());
CHECK(q.pending().size() == 1);
CHECK(intentFor(q, "{A}") && intentFor(q, "{A}")->park);
} }
int main() { int main() {
testParkEnqueuesOneIntentCarryingNoOps();
testParkReportsNothingCancelledWhenNoIntentWasPending();
testParkOnItsOwnPendingParkReportsNothingCancelled();
testRestoreOnAnUndrainedParkCancelsRatherThanStacks();
testParkOnAnUndrainedRestoreCancelsRatherThanStacks();
testRepeatedParkStaysOneIntent();
testLaterRestoreReplacesTheEarlierOnesOps();
testOneTracksCancelLeavesEveryOtherTrackAlone();
testCancelledTrackCanBeQueuedAgain();
testClearDropsEverythingPending();
testParkHandsBackTheOpsOfTheRestoreItCancelled();
testCancelledRestoreOpsBecomeTheFreshSnapshotsFxHalf();
testNothingCancelledLeavesTheFxHalfToTheCaller();
testSlotKeyedRestoreDoesNotBecomeIdentityKeyedWithNoIdentities();
testRestorePlanOpsRebuildTheIdentityKeyedSnapshotVerbatim();
testRestorePlanOpsRebuildTheSlotKeyedSnapshotVerbatim();
testAChainSittingAtEveryValueTheParkWouldWriteReadsAsParked(); testAChainSittingAtEveryValueTheParkWouldWriteReadsAsParked();
testOneFlagStillAtTheUsersValueMeansNoParkReachedTheChain(); testOneFlagStillAtTheUsersValueMeansNoParkReachedTheChain();
testAnEmptyPlanProvesNothing(); testAnEmptyPlanProvesNothing();
@@ -438,9 +188,9 @@ int main() {
testADifferentOwnerReportsEvenWithTheSameNames(); testADifferentOwnerReportsEvenWithTheSameNames();
testAFreshEmptySetReportsAndResetsTheMemo(); testAFreshEmptySetReportsAndResetsTheMemo();
testTakeDetachesEverythingAndLeavesTheQueueEmpty(); testARealSwitchAlwaysLeavesAPointEvenWithNothingToWrite();
testIntentsArrivingDuringADrainSurviveIt(); testAReapplyThatWroteSomethingLeavesAPoint();
testAReEntrantParkCancelsOnlyWhatIsStillPending(); testAReapplyThatWroteNothingLeavesNoPointAtAll();
if (g_fail == 0) std::printf("All tests passed.\n"); if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;