From 8bf6841f7ba6df628295252314c806de48ad2735 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sat, 1 Aug 2026 19:01:01 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20Phase=20=CE=A8=20=E2=80=94=20the?= =?UTF-8?q?=20extension=20trust=20pass,=20seven=20items=20across=20two=20w?= =?UTF-8?q?aves,=20six=20tracks,=20three=20scheduled=20invariant=20amendme?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PLAN.md | 753 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 748 insertions(+), 5 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index 0eab6b8..d0cf0e6 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -4,7 +4,10 @@ The post-1.0 roadmap. Seventeen queued items consolidated into overlapping areas sequenced into a Phase → Wave → Track hierarchy that implementation specialists can be dispatched against directly — **plus Phase Γ**, which did not come from those seventeen (it came from a direct interview, 2026-08-01) and is scoped in -`docs/product/instrument-control-surface.md`. +`docs/product/instrument-control-surface.md`, **and Phase Ψ**, which likewise did not +come from the seventeen: it came from a direct list of seven defects and refinements +(Daniel, 2026-08-01) and is specified inline in its own section below — there is no +backing product doc for it. ## What this doc is, and how it relates to the others @@ -25,8 +28,9 @@ dispatched against directly — **plus Phase Γ**, which did not come from those cited section rather than reading a file whole. **Worktree slug convention:** `p-w-t-`. Greek phase letters -transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**. So Θ-W1-T1 dispatches into -`pth-w1-t1-zone-retirement` and Γ-W1-T1 into `pg-w1-t1-knob-interaction-law`. +transliterate: **Θ → `th`**, **Ξ → `xi`**, **Γ → `g`**, **Ψ → `psi`**. So Θ-W1-T1 +dispatches into `pth-w1-t1-zone-retirement`, Γ-W1-T1 into `pg-w1-t1-knob-interaction-law`, +and Ψ-W1-T1 into `ppsi-w1-t1-capture-range-exactness`. ## Decision state @@ -113,8 +117,11 @@ in Γ-W4-T1 and changes no wave boundary. ## Phase-wide acceptance criteria -These bind every track in all three phases and are stated once here rather than repeated -per track. **Phase Γ adds a set of its own**, stated in its phase header. +These bind every track in all four phases and are stated once here rather than repeated +per track. **Phase Γ adds a set of its own**, stated in its phase header. **Phase Ψ adds +none** — the structural heuristics, performance guardrails, and product invariants below +bind it exactly as written (its phase header states its performance posture against the +named hot paths). ### Structural (root `CLAUDE.md`, Daniel 2026-07-28) @@ -2029,6 +2036,719 @@ value semantics, any deck geometry, or the bake's reset *membership* (W3-T2's). --- +## Phase Ψ — The extension trust pass: exact bounds, disjoint solo surfaces, reachable actions, honest drops, real names, true mono + +**Ships:** capture ranges that mean what was asked — a time selection or razor area over +a longer item captures the selection, both scopes; per-mode solo surfaces that cache, +clear, and restore across the Design/Arrange switch, with the switch itself refused while +the transport runs; the Media-Explorer import action registered into the Media Explorer +action section so it can live on that toolbar; a drag-out gesture law that resolves its +target from what is actually under the cursor, continuously and reversibly, with no +silent no-op release anywhere; captures labeled after their source track instead of the +literal `"item"`/`"track"`, surfaced on the panel card; and lossless mono collapse for +new captures whose channels are bit-identical. + +**Consolidates: none of the seventeen.** Phase Ψ came from a direct list of seven defects +and refinements (Daniel, 2026-08-01). There is no backing product doc — the design +content lives inline in the tracks below, and the seven are recorded here as the phase's +provenance, cited throughout as Ψ.1–Ψ.7: + +> **Ψ.1** — item and track captures should be named (labeled) after their source track +> name, plus a discriminator (date, etc.); currently they aren't named anything useful. +> **Ψ.2** — Design vs. Arrange modes: any SOLO state in one mode is cached and removed +> when switching to another mode, disjoining the solo surfaces. +> **Ψ.3** — the Design/Arrange active-mode toggle is gated if playback is running; only +> allow the switch when the project is not playing. +> **Ψ.4** — the action that moves a Media Explorer item to a new ReaSampler on the +> selected track is not in the Media Explorer action category, so it cannot be added to +> the Media Explorer toolbar; fix this. +> **Ψ.5** — drag-and-drop targets are inexact: sometimes dropping into the arrange +> doesn't work, sometimes dropping into the FX area doesn't work; dragging between banks +> is fine. +> **Ψ.6** — capture into mono: if the left and right channels of a new capture are +> bit-identical, collapse to mono — one channel of data, mono arrange items, ReaSamplers +> load in mono mode. +> **Ψ.7** — capture item / capture track with a small time selection on a large item +> captures the entire item, not the selection/razor; make capture regions consistent and +> correct. + +**Where the seven land:** + +| Ψ-item | Track | Worktree slug | +|---|---|---| +| Ψ.7 | Ψ-W1-T1 | `ppsi-w1-t1-capture-range-exactness` | +| Ψ.2 + Ψ.3 | Ψ-W1-T2 | `ppsi-w1-t2-mode-switch-discipline` | +| Ψ.4 | Ψ-W1-T3 | `ppsi-w1-t3-media-explorer-section` | +| Ψ.5 | Ψ-W1-T4 | `ppsi-w1-t4-drop-target-resolution` | +| Ψ.1 | Ψ-W2-T1 | `ppsi-w2-t1-capture-naming` | +| Ψ.6 | Ψ-W2-T2 | `ppsi-w2-t2-mono-collapse` | + +Ψ.2 and Ψ.3 share one track deliberately: they share one chokepoint — `applyMode`, the +sole mode mutator (`shell/view/view.cpp:380-469`) — and one discriminator +(`targetModeId != model.activeModeId()`, the test that distinguishes a real switch from a +reapply). Splitting them is two tracks fighting over the same function. + +**Concurrency with Γ and Ξ.** Phase Ψ is extension-side. Γ and Ξ-W3 live in +`core/instrument/` + `shell/instrument/`; the surfaces are disjoint with ONE named +exception — Ψ-W2-T2 touches `shell/instrument/processor_reload.cpp` for a stale comment +and an index/file consistency check, and that touch is bounded to the minimum in its +surface boundary precisely because Γ is live in that directory. + +**Three invariant amendments are DELIVERABLES of this phase, not asides.** Each is +scheduled in — and an acceptance criterion of — its owning track. This spec schedules +them; the implementing track performs them: + +1. **The never-touch-solo rule** (Ψ-W1-T2): `src/shell/view/CLAUDE.md:14-17`, + `src/core/view/CLAUDE.md:10`, and `docs/product/design-view.md:160-165` + `:588-592`. + Ψ.2 requires writing `I_SOLO`; the amended form is stated in the track. A track that + lands solo writes without the amendment reads as an invariant breach in review. +2. **The action-registration contract** (Ψ-W1-T3): root `CLAUDE.md` + §"REAPER extension contract" documents only the main-section 4-step pattern; the + second, non-main mechanism (`custom_action` + `hookcommand2` + `-custom_action` + mirror) must be added beside it. +3. **The channel-count-preserved rule** (Ψ-W2-T2): root `CLAUDE.md:208` — "channel count + preserved (no silent stereo fold)" — is contradicted in text (not in spirit) by a + lossless bit-identical collapse, and was already untrue in the other direction (a mono + source renders at `RENDER_CHANNELS=2` today). The amended form is stated in the track. + +**Performance posture.** Every Ψ surface is cold — per-capture, per-click, +per-mode-switch, per-mouse-move. None of the named hot paths (peaks envelope compute, +audition, realtime-capture tick, instrument `process()`) is touched. Two disciplines +carry anyway: the realtime tick's single-pointer-test idle fast path is untouched by +Ψ-W1-T1's render work, and Ψ-W1-T4 keeps the inside-client drag path free of SDK +hit-tests, exactly as today (`panel_drag.cpp:273-274` — "costs nothing on the common +internal-drag path"). + +--- + +### Ψ-W1 — Exact bounds, disciplined switches, reachable actions, resolved drops + +**Depends on:** nothing in this phase. **Four tracks, disjoint by surface:** + +| Track | Owns | +|---|---| +| **T1** `capture-range-exactness` | `core/capture/render_settings`, `shell/capture/capture.cpp`'s render-configuration block, `shell/capture/scope_resolve` (source-mode selection only), `tests/test_render_settings.cpp` | +| **T2** `mode-switch-discipline` | `core/view/view_mode_model` (solo cache), `shell/view/view.cpp` (`applyMode` seams + WANT block), `shell/actions/design_view_actions.cpp` (refusal feedback), `shell/panel/panel_input.cpp` **footer mode-segment block only** (`:300-309`), the mode segment's disabled state in `core/ui/footer_bar` + `shell/panel/panel_render.cpp` | +| **T3** `media-explorer-section` | `shell/actions/ingest.cpp` (register/dispatch/unregister), **the registration block in `src/app/main.cpp`** (the `hookcommand2` hook + unload mirror), the root-`CLAUDE.md` contract amendment | +| **T4** `drop-target-resolution` | `core/ui/drag_out`, `core/wire/instrument_drop`, `shell/panel/panel_drag.cpp`, `shell/actions/instrument_drop_win.cpp`, `shell/actions/drag_out_win.cpp` (hand-off timing), `tests/test_drag_out.cpp` | + +**Why these four are parallel.** T1 is capture core/shell; T2 is view core/shell plus one +fenced block of `panel_input.cpp`; T3 is action registration; T4 is the drag chain. No +two tracks own the same function anywhere. + +**Two shared-file adjacencies, named rather than discovered at merge.** +(a) `shell/panel/panel_input.cpp`: T2 owns the footer mode-segment block (`:300-309`) +only; T4 may touch the drag-arm block (`:382-402`) only. Separate blocks — textual +adjacency, not semantic contention; whichever lands second rebases. +(b) **`src/app/main.cpp` is T3's exclusively.** T4 must not touch `main.cpp` — its whole +redesign lives in the drag chain and registers nothing. T2 also does not touch it: the +playback gate lives inside `applyMode` conditioned on the real-switch discriminator, so +`main.cpp:173`'s project-load reapply passes through unchanged. + +#### Ψ-W1-T1 — `capture-range-exactness` + +**Goal.** A capture over a time selection or razor area on a source item substantially +longer than the selection produces exactly the requested range — both scopes, no +whole-item widening. (Ψ.7) + +**What is already correct, so the fix does not wander.** Range resolution is correct end +to end: `scope_resolve.cpp:100-112` (`resolveRange`) → razor union +(`render_settings.cpp:135-170`) or `GetSet_LoopTimeRange` (`scope_resolve.cpp:35-40`), +passed verbatim (`capture_orchestrator.cpp:221-222`), landed as +`RENDER_BOUNDSFLAG=0` + exact `RENDER_STARTPOS`/`RENDER_ENDPOS` +(`capture.cpp:320-322`). There is no item-bounds fallback in `RunCapture`. **The widening +decision point is the source-mode bit**: item scope maps to +`kRenderSelItems (&32) | kRenderSingleFile` (`render_settings.cpp:82-86`), and the repo's +own comments treat `&32` as item-extent-driven (`capture_batch.cpp:64-66`, `:98-99` — +the entire reason batch transiently selects one item per render). The working inference — +**unverified without a DAW** — is that REAPER's "selected media items" render source +overrides the custom time bounds. Track scope uses `&128` (selected tracks via master), a +normal time-bounded render, and **no code path was found that widens it**. + +**Behavior.** +- **The track opens with a DAW repro matrix, before any code change** `[verify]`: + item scope × {time selection, razor} and track scope × {time selection, razor}, over a + source item substantially longer than the selection, **tail mode None**. This confirms + the `&32`-overrides-bounds inference, and disambiguates Ψ.7's "item/track both" claim — + the candidates are: the reporter saw only the item scope; or a non-`None` panel tail + mode was active (`capture_orchestrator.cpp:217`; Auto adds an 8 s window, + `render_settings.cpp:28-37`). If track scope reproduces with tail None, the analysis + above is wrong and the track says so before proceeding. +- **The fix architecture is a design call at implementation review** `[propose]`, with + the two candidates named now: + - **(a) Re-source the ranged item capture.** When item scope carries an explicit range, + render time-bounded (e.g. selected-track source `&128` over the item's own track, + with the item-scope FX-bypass plan unchanged — it already neutralizes the track's own + track-FX chain and everything above). Semantic edge to resolve: another item on the + same track overlapping the range would now be audible in the capture, where `&32` + excluded it. + - **(b) Render-then-trim.** Keep `&32` (only the selected item's audio, REAPER's own + semantics) and trim the rendered file to the requested range afterward with the pure + `wav_codec` tools, sample-exactly (offset = requested start − rendered start at the + file's sample rate). Precedent: `trimAutoTailInPlace` + (`capture_realtime_finalize.cpp:55-125`). Cost: renders more than needed; requires + knowing the rendered file's start time exactly. + Whichever candidate lands, the choice is judged against the acceptance criteria below, + not against convenience. +- **Ripple surfaces if the item-scope source mode changes**, enumerated so none is + discovered late: `capture_batch.cpp:98-99`'s select-one mechanism, item-scope FX + fingerprinting reading `CountSelectedMediaItems` (`scope_resolve.cpp:199-207`), and + `core/model/provenance` recipes storing `sourceMode` as an int + (`scope_resolve.cpp:186`) — a recorded recipe must replay to the same audio. +- **Batch semantics are deliberately different and stay so:** `RunBatchCaptureItems` is + one sample per item at item extent (`capture_batch.cpp:177-179`) — that is its meaning, + not this defect. Stated so nobody "fixes" it. + +**Acceptance criteria.** +- **The bounds equality, stated as a number:** the captured file's frame count equals the + requested range's duration at the project sample rate — exactly, no rounding — for + BOTH scopes, with both a time selection and a razor area, over a source item + substantially longer than the selection, tail None. `[verify — DAW]` +- The null test holds for a ranged item capture: re-inserted at the range start, it nulls + against the source over the range. `[verify — DAW]` +- Bit-identical repeats hold for the ranged capture. +- Full-item captures (no time selection/razor, or bounds equal to the item) are + byte-identical to today's output. +- Recapture-from-source of a ranged capture reproduces it (the provenance recipe stays + truthful under the chosen fix). +- `tests/test_render_settings.cpp` pins the chosen mapping (today `:44-47` asserts only + that the `&32` bit is chosen — never its bounds interaction); any new pure trim/window + arithmetic lands in `core/capture` with unit tests. +- **DAW-verification obligation:** Daniel observes the four-cell matrix above, plus one + razor-union case (two disjoint razor areas — the union rule at + `render_settings.cpp:135-170` is bounds-driven and must not regress). +- Root `CLAUDE.md:208`'s **"Exact bounds"** invariant needs no amendment — this track + makes the code honor it; the invariant was always the spec. + +**Open questions.** `[verify]` the override inference and the track-scope repro (the +matrix above). `[propose]` fix candidate (a) vs (b), including (a)'s overlapping-item +semantic edge and (b)'s rendered-start-time derivation. + +#### Ψ-W1-T2 — `mode-switch-discipline` + +**Goal.** Switching the active mode caches and clears the outgoing mode's solo state and +restores the incoming mode's — disjoint solo surfaces per mode — and the switch itself is +refused, visibly, while the transport is playing or recording. (Ψ.2 + Ψ.3) + +**The invariant amendment comes first, because without it this track is a breach.** The +never-touch-solo rule is stated three times: `src/shell/view/CLAUDE.md:14-17` ("Never +touches master or `B_MUTE`/`I_SOLO`… User mute/solo survives every toggle untouched"), +`src/core/view/CLAUDE.md:10`, and `docs/product/design-view.md:160-165` (framed as the +analog of capture's non-destructive invariant) + `:588-592`. **The amended form:** the +tool never *loses* the user's solo state — solo is cached per mode on a real switch and +restored verbatim on return, the snapshot sense of non-destructive, exactly as the +park/restore snapshots already treat visibility and FX state. `B_MUTE` stays untouched +absolutely; the master track stays untouched absolutely (and survives for free — +`handleByGuid` is built from `GetTrack`'s index space, which excludes master, +`view.cpp:90-91`). All three files are amended by this track. + +**Surface boundary — owns:** `core/view/view_mode_model` (the solo cache: storage, +serialize/deserialize, reconcile participation — mirroring the existing `snapshots_` map, +`view_mode_model.h:284-358`), `shell/view/view.cpp` (the `applyMode` seams and its +`REAPERAPI_WANT` block, `:21-41`), `shell/actions/design_view_actions.cpp` (refusal +feedback on the action path), `shell/panel/panel_input.cpp`'s footer mode-segment block +ONLY (`:300-309`), the segment's disabled state in `core/ui/footer_bar` (pure predicate, +transport bool passed IN from the shell) and its paint in `shell/panel/panel_render.cpp` +(`:110-125` region), the three invariant-amendment files, `tests/test_view_mode_model.cpp`. +**Must not touch:** `panel_drag.cpp` (T4's), `src/app/main.cpp` (the gate's discriminator +makes the project-load reapply pass through unchanged), `B_MUTE`, master, and the +park/restore planner's existing semantics. + +**Behavior.** +- **The discriminator is the hinge for both halves.** `applyMode` is also the reapply + path — called with `targetModeId == activeModeId()` from `reapplyActiveMode` + (`design_view_actions.cpp:106-108`, reached from tag/untag/show-both at + `:187,197,206`) and from project load (`main.cpp:173`). A real switch is + `targetModeId != model.activeModeId()`. **Only a real switch caches/clears/restores + solo, and only a real switch is playback-gated** — reapply during playback (tagging, + project load) keeps working, and there is no cache/clear/restore flicker on reapply. +- **Solo cache semantics.** On a real switch: read `I_SOLO` for every track in the live + enumeration (`handleByGuid`, keyed by GUID — all non-master tracks, not just managed + leaves: a mode's solo surface is the whole project as seen in that mode); store the + non-zero values into the model keyed by the OUTGOING mode id (`model.activeModeId()` + is still the outgoing mode at the capture seam); write `I_SOLO = 0` on every track that + had it set; after `setActiveMode` (`view.cpp:455`), restore the INCOMING mode's cached + entries verbatim and consume them (clear-on-restore, mirroring + `storeSnapshot`/`clearSnapshot`). **Values are cached and restored verbatim as the raw + `I_SOLO` int — never collapsed to a boolean** (solo-in-place and safe-solo variants + survive the round trip). +- **Seams in `applyMode`:** capture after `handleByGuid` is populated (`:386`) and before + `Undo_BeginBlock2` (`:399`); the clear and restore writes both ride the existing single + undo block (clear at its head, restore after `setActiveMode` `:455` and before + `TrackList_AdjustWindows` `:460`) — one mode toggle stays one Ctrl-Z. +- **Storage is pure, shell touches only the API pair.** The cache lives in + `ViewModeModel` beside `snapshots_` with the same `deserialize(serialize(x)) == x` + contract (`view_mode_model.h:347-350`); only the + `GetMediaTrackInfo_Value`/`SetMediaTrackInfo_Value` pair lives in `view.cpp`, exactly + as `snapshotTrack` (`:121-134`) does. `core/` includes no REAPER headers. +- **Reconcile participation — decided: the solo cache prunes on dead GUIDs**, like + snapshots and unlike membership (`view.cpp:389-395`). The hazard it removes: a stale + entry restoring onto a reused GUID. A pruned entry is simply lost solo state for a + track that no longer exists — correct. +- **Persistence rides the one blob.** `ViewModeModel::serialize()` → + `SetProjExtState(proj, "reasampler", "view_state")` (`ext_state_io.cpp:156-158`, key + `ext_keys.h:31`). `parseModel` skips unknown keys (`view_mode_model.cpp:603`), so the + new key is backward-safe: an older build reading newer state drops the solo cache + silently and nothing else breaks. Stated, accepted. +- **The playback gate.** Inside `applyMode`, when the discriminator says real switch and + `GetPlayStateEx(proj) & (1|4)` (bit 1 playing, bit 4 recording — the precedent is + `capture_realtime_shell.cpp:284`, project-scoped `*Ex` variants deliberately, + comment `:192`), refuse before touching anything: return `false`, no partial apply + (the same fail-closed shape as the mode-exists guard at `:381`). Add + `REAPERAPI_WANT_GetPlayStateEx` to `view.cpp`'s WANT block. +- **The refusal is visible, not a silent no-op.** The footer `[Arrange|Design]` segment + renders a disabled state while the transport runs, following the one existing disabled + precedent end to end: pure predicate (`mode_enable`-style, transport bool passed IN so + `core/ui` stays REAPER-free) → layout/paint state → no-op click. The action path's + refusal (`doToggleMode`/`doActivateMode`) reads `applyMode`'s `false` and skips + persist/invalidate. +- **The panel's direct-call divergence is closed in this track — decided.** The footer + segment click calls `applyMode` directly (`panel_input.cpp:300-309`, passing `nullptr` + project), skipping the `persistViewState()` + `bankPanelInvalidate()` the action path + does (`design_view_actions.cpp:165-180`) — a pre-existing defect: a panel-initiated + switch does not persist. The segment click now routes through `Main_OnCommand` of the + activate actions, exactly as the bottom-bar tag buttons already do + (`panel_input.cpp:74-75`). One path, one gate, one persist. + +**Acceptance criteria.** +- **The disjoint-surface matrix, DAW-observed:** solo two tracks in Arrange; switch to + Design → no track is soloed; solo a third track in Design; switch back → exactly the + two Arrange solos are restored (raw `I_SOLO` values verbatim, including solo-in-place) + and the Design solo is gone; switch forward again → the Design solo is restored. +- Reapply paths (tag/untag/show-both, project load) neither clear nor flash solo state — + before or after this track, during playback or stopped. +- With the transport playing or recording: the switch is refused on EVERY path (action, + panel segment), the segment visibly renders disabled, and project state is untouched by + the refused attempt. Stopped → the switch works. Recording gates identically to + playing. +- A panel-initiated switch now persists (the divergence closure): switch via the footer + segment, save, reload → the project reopens in the switched mode. +- Solo cache round-trips save/reload: save mid-disjunction, reload → the inactive mode's + cached solos restore on the next switch. +- One mode toggle remains one Ctrl-Z; `B_MUTE` is never written; master is never touched. +- The three invariant-amendment files land amended, in this track. +- Pure model changes covered in `tests/test_view_mode_model.cpp` (store/consume, + serialize round-trip, reconcile pruning); the enable predicate is pure and tested. +- **DAW-verification obligation:** Daniel runs the solo matrix, the playback-gated + refusal (playing AND recording), and the panel-persist case. + +**Open questions.** `[verify]` `I_SOLO` is settable via `SetMediaTrackInfo_Value` and its +value domain — against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h`, per root +`CLAUDE.md`'s API-verification rule (settability is inference today). `[verify]` adding +`REAPERAPI_WANT_GetPlayStateEx` to `view.cpp` alone compiles (`main.cpp:15` does not +define `REAPERAPI_MINIMAL`, so it should — inference, one compile answers it). +`[propose]` the repaint trigger for the disabled segment (the panel must notice transport +transitions; lean: poll play state in the panel's existing timer tick and invalidate on +change). `[propose]` restore-onto-parked-track edge: a track soloed in mode B, then +re-tagged so it is parked when B next activates, would on verbatim restore solo a +silent track and mute the mix — lean: restore skips tracks parked in the incoming mode +and drops those entries, with the alternative (retain until the track re-enters) named +and rejected as zombie state. + +#### Ψ-W1-T3 — `media-explorer-section` + +**Goal.** The Media-Explorer import action appears in the Media Explorer action section, +so it can be bound and placed on the Media Explorer toolbar. (Ψ.4) + +**Why it lands in Main today (SDK-verified):** the action registers via `"gaccel"` +(`ingest.cpp:457-468`), and `gaccel_register_t` registers into the main keyboard section +only — the struct has no section field (`vendor/reaper-sdk/sdk/reaper_plugin.h:1106-1117`). +**The supported mechanism (SDK-verified):** `custom_action_register_t` +(`reaper_plugin.h:1090-1103`) — `{uniqueSectionId, idStr, name, extra}`, with **Media +Explorer = section 32063** (`:1099`, corroborated `reaper_plugin_functions.h:3467`); +`Register("custom_action", &ca)` returns the command id or 0; `idStr` must be unique +**across all sections** (`:1100`). Dispatch for non-main sections MUST come from +`"hookcommand2"` (`reaper_plugin.h:212-221`) — `"hookcommand"` runs only for the main +section (`:200-205`), and `main.cpp:309` registers only `"hookcommand"` today. +`custom_action_register_t` carries no `ACCEL`, so the non-Main entry ships no default +keybinding — acceptable; toolbar placement is the ask. + +**Settled, not a fork: the action registers in BOTH Main and Media Explorer.** Ψ.4 asks +for reach, not relocation, and a bare move would orphan any existing Main-section +keybinding — a half-fix. Because `idStr` is unique across all sections, the Media +Explorer registration **mints a second FOREVER-STABLE command-id suffix:** +**`INGEST_IMPORT_MEDIA_EXPLORER_MX`**, composed per channel via `channelCommandId` like +every other id (`core/version/app_version.cpp:43-47,84-86`) — a new permanent entry in +each channel's id family, never to change after shipping. The existing +`INGEST_IMPORT_MEDIA_EXPLORER` string is preserved unchanged for the Main entry, so root +`CLAUDE.md:180-183`'s never-change contract is honored literally. Both registrations +share the one handler (`doImportFromMediaExplorer`, `ingest.cpp:317-395`). + +**Surface boundary — owns:** `shell/actions/ingest.cpp` (the second registration, the +`hookcommand2` claim, the `-custom_action` unload mirror beside the existing mirrors at +`:476-481`), **the registration block in `src/app/main.cpp`** (registering the +`"hookcommand2"` hook beside `"hookcommand"` at `:309`, and its unload mirror at the +`rec == nullptr` path), and the root-`CLAUDE.md` §"REAPER extension contract" amendment +(the second registration mechanism, documented beside the 4-step main-section pattern). +**Must not touch:** any other action family's registration, `panel_*`, `capture*`. T4 +must not touch `main.cpp`; this track is why. + +**Behavior.** +- Main-section registration byte-identical to today: same id string, same gaccel, same + keybindings surviving. +- Second registration: `custom_action_register_t{32063, channelCommandId(new suffix), + channelActionName(same display phrase — decided: identical phrase in both sections, + for findability), nullptr}` via `Register("custom_action", ...)`. A 0 return is + tolerated gracefully (the Main entry still works; a console note, not a failure). +- `hookcommand2` claims ONLY the new MX command id and returns `false` otherwise — + `hookcommand2` fires for every section including Main, and the Main id stays claimed by + the existing `hookcommand` path (`ingest.cpp:470-474` via `main.cpp:222-230`); the two + ids differ, so no double dispatch is possible. Stated as a criterion, not an accident. +- Unload mirrors everything: `-custom_action` with the same struct/pointer, alongside the + existing `-gaccel`/`-command_id`. +- Beta channel forks both ids automatically through `channelCommandId` — each channel + gains exactly one new permanent id. + +**Acceptance criteria.** +- The Actions list, section "Media Explorer", shows the channel-prefixed action; it can + be added to the Media Explorer toolbar; firing it from that toolbar imports the last + played Media Explorer file into a ReaSampler on the selected track — identical behavior + to the Main-section entry. `[verify — DAW]` +- The Main-section entry is unchanged: same command id, existing keybindings intact. +- Extension unload unregisters both entries cleanly; reload re-registers both. +- The new suffix `INGEST_IMPORT_MEDIA_EXPLORER_MX` is recorded as FOREVER-STABLE, per + channel, in the same breath as the registration lands. +- Root `CLAUDE.md`'s "REAPER extension contract" section gains the non-main mechanism + (`custom_action` / `hookcommand2` / `-custom_action` mirror) — a deliverable of this + track. +- **DAW-verification obligation:** Daniel adds the action to the Media Explorer toolbar + and imports from it; also confirms the Main-section binding still fires. + +**Open questions.** `[verify]` re-verify `custom_action_register_t` and `hookcommand2` +argument order/types against the SDK headers at implementation time (root `CLAUDE.md` +rule; the section ids and struct shapes above are already header-verified, the rule +applies regardless). None classified `[propose]` — the ruling above is settled. + +#### Ψ-W1-T4 — `drop-target-resolution` + +**Goal.** Dragging cards out of the panel resolves its target from what is actually under +the cursor — continuously, reversibly, with a defined outcome and a visible cue for every +surface, and no silent no-op release anywhere. (Ψ.5) + +**The root causes, so the redesign is judged against them.** The current chain locks a +drag's fate at its first processed move outside the client rect +(`core/ui/drag_out.cpp:18-26`): a single-card drag over ANY REAPER surface — +`GetThingFromPoint` returns non-empty info for the arrange too +(`instrument_drop_win.cpp:84-86`) — locks to `InstrumentDrop` and returns before the OS +hand-off is ever evaluated (`panel_drag.cpp:285-294`); over the arrange the info string +fails the FX-hotspot rule, so release falls to a **silent no-op** (root cause A — the +verified headline: single-card arrange drops do nothing, while multi-card drags skip the +FX resolve (`:276`) and work via OS drag, which is exactly Ψ.5's "sometimes"). The +`OsDrag` branch is irreversible — `ReleaseCapture` + `resetDragState` + modal +`DoDragDrop` (`:299-335`) — and `WM_MOUSEMOVE` coalescing makes the deciding exit pixel +vary with drag speed (root cause B). The FX "hotspot" is only the FX-button glyph family +(`fx_*`/`tcp.fx*`/`mcp.fx*`, `core/wire/instrument_drop.cpp:90-95`) — TCP body/name, or +a TCP too narrow to draw the button, yields `"tcp"` and a cue-less no-op (root cause C). +Multi-card drags can never instrument-drop (root cause D). One unresolvable hand-off +evaluation sets `dragOsHandoffBlocked` for the remainder of the drag (`:320`, cleared +only in `resetDragState` — root cause E). Coordinate spaces and DPI are NOT causes +(verified); bank-to-bank never leaves the client and is CTest-covered +(`tests/test_drag_out.cpp:33-112`) — consistent with "dragging between banks seems +fine", and it must stay byte-identical. + +**The redesign is a gesture law, not a patch.** The law: **the target class is resolved +from what is under the cursor on every move, every class transition is reversible until +release or until the pointer leaves REAPER entirely, and the OS hand-off is reserved for +leaving REAPER** — every REAPER-internal target is executed natively on release. The +minimal patch (re-evaluating the hotspot per move but keeping the early irreversible +`OsDrag` fork) is named and rejected: it re-creates root cause B — a drag that crosses +the arrange en route to an FX button would still lose the instrument drop forever. + +**The class enumeration and its defined outcomes (single-card / multi-card):** + +| Under the cursor | Single card | Multi card | +|---|---|---| +| Inside the panel client | Internal drag (unchanged, byte-identical) | Internal drag (unchanged) | +| FX hotspot (`fx_*` window, or track panel — see below) | **Instrument drop** (existing `.vstpreset` path) | **Refuse-with-cue** (no instrument drop for a multi payload — root cause D becomes a defined, cued outcome) | +| TCP/MCP, whole panel (not just the FX glyph; `*.fxembed` still excluded) | **Instrument drop — decided:** the whole track panel is the hotspot. Root cause C IS the FX-glyph-only rule; a capture dropped on a track's panel means "sampler on this track." The rejected alternative — TCP drop = item at edit cursor — is silent-placement-adjacent and has no time coordinate; the arrange owns placement. | Refuse-with-cue | +| Arrange | **Timeline item at the pointer's track/time** — native insertion on release | Native insertion, shape at review (see open questions) | +| Elsewhere in REAPER (ruler, transport, docker chrome) | Refuse-with-cue | Refuse-with-cue | +| Outside REAPER entirely | OS drag-out (`DoDragDrop`, CF_HDROP, copy-only — unchanged mechanics, new trigger condition) | OS drag-out | + +- **Every release either performs the resolved action or visibly refuses** — the cue + (cursor via the existing `applyDragCursor` precedent, plus the panel's drag paint + state) tracks the resolved class continuously, so "will this work" is visible before + release, and no cell in the matrix is a silent nothing. +- **The blocked flag is retired as a drag-lifetime latch** (root cause E): an + unresolvable evaluation refuses that evaluation, not the rest of the drag. +- **The arrange outcome does not violate the load-bearing principle, and the spec says so + where a reviewer will look:** the item is placed because the USER dragged it to that + spot — user-initiated placement, the same class of act as `RunInsertSelected`, not a + capture auto-insert. Root `CLAUDE.md`'s "capture and placement are separate acts" is + about capture never placing; a deliberate drop is placement on demand. +- **The pure decision stays pure.** The class enumeration, its precedence, and the + single/multi split land in `core/ui/drag_out` (the law) + + `core/wire/instrument_drop` (the info-string classification), CTest-covered over the + full matrix; the shell reads live pointer/window state and executes outcomes, exactly + the discipline `card_drag` already follows. +- **Hot-path discipline:** the inside-client path stays free of SDK hit-tests + (`GetThingFromPoint` is evaluated only outside the client rect, as today — + `panel_drag.cpp:273-274`); per-move resolution outside the client now runs for multi + payloads too, which is new but still per-mouse-move cold. + +**Surface boundary — owns:** `core/ui/drag_out` (the gesture law, rewritten), +`core/wire/instrument_drop` (classification), `shell/panel/panel_drag.cpp` (routing + +release dispatch), `shell/actions/instrument_drop_win.cpp` (hit-test shell), +`shell/actions/drag_out_win.cpp` (hand-off trigger timing), `tests/test_drag_out.cpp`. +May touch `panel_input.cpp`'s drag-arm block (`:382-402`) only — T2's footer block is +fenced. **Must not touch:** `src/app/main.cpp` (T3's — this track registers nothing), +`core/ui/card_drag` and the in-grid reorder/replace semantics (bank-to-bank stays +byte-identical), the capture pillar, the insert action's own semantics. + +**Acceptance criteria.** +- The full matrix above, DAW-observed, single AND multi: every cell produces its defined + outcome with its cue, and in particular — a single-card drop onto the arrange lands an + item at the pointer's track and time (the headline defect); a single-card drop onto a + track panel (not just the FX glyph) loads the instrument; a multi-card drop onto the + arrange still lands items; every refuse cell visibly refuses. +- **Reversibility:** leave the client, cross the arrange, reach an FX window → + instrument drop still available; return into the panel client → internal drag resumes. + One unresolvable evaluation does not change any later evaluation's outcome. +- **Drag speed does not change outcomes:** fast flicks and slow drags to the same release + point resolve identically (the coalescing hazard is designed out with the early lock, + not mitigated). +- Bank-to-bank drags are byte-identical to today; `tests/test_drag_out.cpp:33-112` (or + their successors) stay green with unchanged semantics. +- The pure law's CTest matrix covers every class × single/multi combination, including + the `*.fxembed` exclusions and the null-track/non-empty-info cases the SDK documents + for `GetThingFromPoint` (`reaper_plugin_functions.h:3440-3446`). +- No new SDK hit-test inside the client rect. +- **DAW-verification obligation:** Daniel runs the matrix, explicitly including the + narrow-TCP case (root cause C's trigger), fast-flick drags, and a drag that crosses the + arrange before reaching an FX window. + +**Open questions.** `[verify]` pointer→(track, time) for the native arrange insertion — +candidate API `GetSet_ArrangeView2` (pixel-column → time mapping) plus +`GetThingFromPoint`'s track; verify signatures and behavior against the SDK header +before building on them; if no exact pixel→time mapping exists, the arrange outcome's +mechanism (not its existence) is re-proposed. `[verify]` what REAPER does with a CF_HDROP +drop re-entering its windows during our `DoDragDrop` (the accepted residual once the +pointer has left REAPER and returns mid-modal-loop — confirm it is REAPER's own file +import, then state it). `[propose]` the multi-card arrange insertion shape (lean: one +item per file mirroring REAPER's own multi-file drop convention; alternative: retain the +OS hand-off for the multi-arrange cell only — costs a second delivery mechanism and a +modal fork, named to be rejected on uniformity unless the native shape fails +verification). `[propose]` the exact refuse-with-cue rendering (cursor-only vs cursor + +panel status text). + +--- + +### Ψ-W2 — Names and channels, over the settled render block + +**Depends on Ψ-W1 — specifically Ψ-W1-T1**, which rewrites the capture +render-configuration block in `shell/capture/capture.cpp` and +`core/capture/render_settings.cpp`; both W2 tracks edit adjacent regions of those same +TUs, so W2 dispatches only after W1-T1 is on `dev`. (T2–T4 of W1 gate nothing here; the +wave boundary is the file collision, not a semantic dependency.) + +**Two tracks, parallel, disjoint by field family:** T1 owns the naming fields +(`baseName` / `uniqueTag` / `displayName` / `capture_paths`), T2 owns the channel fields +(`channelCount` / PCM / `wav_codec`). **The known collision seam, named now:** both +tracks touch `shell/capture/capture.cpp` and +`shell/capture/capture_realtime_finalize.cpp` at adjacent lines. In `capture.cpp`, T1 +owns the tag/paths mint (`:310-314`) and the `Sample` id/label lines (`:398-402`); T2 +owns `RENDER_CHANNELS` (`:353-354`), the new post-render collapse step inserted between +the exists-check (`:390-395`) and the `Sample` population (`:398`), and +`stampCaptureSample`'s channel echo (`:207`). In `capture_realtime_finalize.cpp`, T1 owns +the label site (`:195`); T2 owns the layout-parse/channel lines (`:188-198`) and the +collapse insertion. Adjacent lines, disjoint fields — textual merge adjacency, not +semantic contention; whichever lands second rebases. + +#### Ψ-W2-T1 — `capture-naming` + +**Goal.** Captures are labeled after their source track's name plus a discriminator, on +every mint site — and the name is visible where the user looks. (Ψ.1) + +**Today, and why it reads as broken:** every offline capture's `displayName` is the +literal `"item"` or `"track"` (`CaptureActionDef` → `render_settings.cpp:175-183` → +`capture.cpp:402`), the filename is `"item_-.wav"` +(`makeUniqueTag`, `capture.cpp:187-199`; `deriveBankPaths`, +`core/capture/capture_paths.cpp:48-72`) — and **the source track name is read nowhere in +the tree** (repo-wide: zero hits for `P_NAME`/`GetTrackName`; the capture path holds +`MediaTrack*` and GUIDs but never a name). Worse, **the label barely surfaces:** the +panel card draws no name at all (`panel_render.cpp:23-63` — waveform, bars.beats, +seconds.ms only); the sample name appears only inside the VST3 editor. A good name +nobody can see is a half-fix, so this track also surfaces it. + +**Surface boundary — owns:** `shell/capture/scope_resolve` (the ONE place already +walking source tracks before the FX-bypass guard — gains the name read, plumbed through +`ResolvedSource`, `scope_resolve.h:33`), `shell/capture/capture.h` (`CaptureRequest`), +`capture_orchestrator.cpp`'s naming lines (`:229`, `:405`), `capture.cpp`'s mint/label +lines (`:310-314`, `:398-402`), `capture_batch.cpp`'s mint sites (`:220`, `:284`, +`:395`), the realtime label chain (`capture_realtime_shell.cpp:309-310`, +`capture_realtime_finalize.cpp:195`, `core/capture/capture_realtime.cpp:31-32`), +`core/capture/render_settings` only for `CaptureActionDef::baseName` semantics +(`render_settings.h:170-175` — the literals become fallbacks), and the card name +surface: pure geometry in `core/ui` (`bank_grid` / `card_meta`) + its draw in +`shell/panel/panel_render.cpp`. **Must not touch:** `channelCount`/PCM/`wav_codec` +(T2's), the render-configuration block W1-T1 landed (read-only here), +`core/capture/capture_paths`'s purity — it stays REAPER-free path arithmetic with **no +filesystem access** (`core/capture/CLAUDE.md`); any collision handling stays +`makeUniqueTag`'s, shell-side. + +**Behavior.** +- **The name is resolved shell-side in `scope_resolve`,** before the FX-bypass guard, + alongside the GUID walk it already does (`scope_resolve.cpp:53-72`, `:114-127`): + track scope → the selected track's name; item scope → the selected item's owning + track's name. `[verify]` the read API (`GetSetMediaTrackInfo_String` with `"P_NAME"` + vs `GetTrackName`) against the SDK header before use. +- **Label composition:** `displayName = " "`. The + discriminator's exact format is `[propose]` (lean: a compact date-time derived from the + capture's own `createdTimestamp`, e.g. `Bass 08-01 1432` — Daniel's "date, etc." + names it); `displayName` remains explicitly NOT unique, per the existing rule + (`core/model/CLAUDE.md` §`resample_name`) — the discriminator serves legibility, not + uniqueness. +- **The filename becomes meaningful too:** `baseName` = the sanitized track name, so the + stem is `"Bass_.wav"` via the existing `sanitizeStem` + `makeUniqueTag` pipeline — + stem uniqueness remains entirely `makeUniqueTag`'s job, unchanged. A name the sanitizer + reduces to nothing (non-ASCII) falls back to `"capture"` for the stem + (`capture_paths.cpp:24-46`'s existing rule) while `displayName` keeps the real name. +- **Fallback for an unnamed track:** deterministic, matching REAPER's own display + convention (`"Track N"` by index at capture time) — never the old literals on the + interactive paths. +- **All five mint sites covered, each stated:** offline (above); **realtime** — same + resolution at `begin` time (the action has a selected track); **batch** — per-unit + owning-track name, the batch ordinal retained as an additional discriminator + (`"Bass 1"`, `"Bass 2"`… exact composition settled with the discriminator format); + **recapture** — decided: preserves the existing entry's `displayName` (it regenerates + an entry, it does not mint a new identity; only the file stem re-mints); + **ingest** — unchanged (already meaningful: the source file's stem, + `ingest.cpp:249-266`); **bake** — unchanged (`model::nextIterationName`, the + `Kick r2` precedent). **The bake's naming-and-lineage open question is Ξ's, jointly + held with Ξ-W1-T1, and this track does not close or touch it.** +- **The card shows the name — decided in-scope:** one truncated name line on the panel + card, geometry pure (`bank_grid`/`card_meta`, CTest-covered), drawn by palette role + through the kit. Truncation is display-only; the stored value is never shortened. If + review finds the card too crowded at the smallest cell size, the fallback is + name-in-tooltip — `[propose]` at review, with on-card as the lean. +- **Existing bank entries are untouched** — new captures only; no retroactive rename. +- `displayName` already flows to the instrument (component-state copy + one-way refresh, + `component_state_io.cpp:74-88`, `sample_map.cpp:96`) — no instrument-side work. + +**Acceptance criteria.** +- An item capture from a track named `Bass` yields `displayName` `Bass ` + (format as settled at review) and a file `Bass_.wav`; a track capture likewise; + the literals `"item"`/`"track"` never appear as labels on any interactive capture path + again. `[verify — DAW]` +- Realtime and batch captures follow the same scheme (batch retains per-unit ordinals); + recapture preserves the entry's existing name; ingest and bake naming are unchanged. +- An unnamed source track produces the deterministic `Track N` fallback. +- The panel card shows the name, truncated to its cell, pure-geometry tested; the + instrument's browse list shows the same name with no mechanism change. +- File-stem uniqueness is still solely `makeUniqueTag`'s; `capture_paths` gains no + filesystem access; existing entries' labels are byte-identical after the track lands. +- **DAW-verification obligation:** Daniel captures from a named track, an unnamed track, + and a multi-item selection; sees the labels on the card and in the instrument. + +**Open questions.** `[verify]` the track-name read API against the SDK header. +`[propose]` the discriminator format (lean above). `[propose]` multi-track item +selections (items spanning several tracks in one capture — lean: first source track's +name plus a `+N` marker; alternatives: joined names, or the scope literal as fallback). +`[propose]` card-name fallback to tooltip only if the card proves too crowded. + +#### Ψ-W2-T2 — `mono-collapse` + +**Goal.** A new capture whose channels are bit-identical is collapsed losslessly to one +channel — one channel of data on disk, a mono arrange item on insert, the instrument +loading in Mono mode. (Ψ.6) + +**The invariant amendment is part of this track.** Root `CLAUDE.md:208` — "channel count +preserved (no silent stereo fold)" — is honored in spirit (the parenthetical forbids a +LOSSY fold; this collapse is lossless by predicate) but contradicted in text. The +amendment: channel count is preserved except that bit-identical channels may collapse +losslessly to mono; a lossy fold remains forbidden. Noted in the amendment: the old text +was already untrue in the other direction — a mono source renders at `RENDER_CHANNELS=2` +today (`capture_orchestrator.cpp:227`, hardcoded and never measured). + +**Surface boundary — owns:** `core/capture/wav_codec` (the pure bit-identity predicate + +collapse plan, with `wav_codec` unit tests), `shell/capture/capture.cpp` (the post-render +collapse step between the exists-check `:390-395` and the `Sample` population `:398`, +plus `stampCaptureSample`'s channel echo `:207` — derive from the FILE, the +`bake_land.cpp:105,131,177` / `ingest.cpp:199,268` precedent), +`shell/capture/capture_realtime_finalize.cpp` (the collapse on the realtime path, and +the same echo fix — it parses the layout at `:188-198` and STILL echoes the request +value), the root-`CLAUDE.md:208` amendment, and — **minimally, because Phase Γ is live in +`shell/instrument/`** — `shell/instrument/processor_reload.cpp` only for the stale +"always 2 for extension captures" comment (`:64-77` vs `:150-157`) and any index/file +consistency fix that falls out; nothing else instrument-side. **Must not touch:** the +naming fields (T1's), `capture_paths`, the panel, the instrument's channel-mode logic +(`sample_map.cpp:58-62` already maps `channelCount == 1` → Mono, explicit user toggle +winning — no change needed). + +**Behavior.** +- **The predicate is pure and bit-exact:** all channels bit-identical per frame (float + bit patterns, never epsilon) → collapse to 1 channel. Generalized to N channels + (all-identical → mono; no partial collapse, e.g. never 4→2) — N-channel captures are + not reachable today (`channelCount` hardcoded 2), so the generalization is + future-proofing the predicate, stated as such. +- **The collapse mirrors the one existing rewrite precedent** — `trimAutoTailInPlace` + (`capture_realtime_finalize.cpp:55-125`): `parseWavLayout` → `extractFloatFrames` → + `buildFloat32Wav(1, …)` → truncating rewrite, all with the existing `wav_codec` tools + (`wav_codec.h:48-89` — `buildFloat32Wav` already takes arbitrary `nch`). There is no + PCM assembly on the offline path today (REAPER writes the file; the extension only + stats and hashes it) — this step is the first, and it lives shell-side at the named + insertion point with its plan pure. +- **Scope — decided: every extension capture path** — offline, realtime, batch, + recapture (batch and recapture route through the same backends). **Ingest is + excluded** — imported files are the user's bytes, not our capture; rewriting them is a + mutation this tool has no license for. **Unconditional, no user opt-out** — Ψ.6 asks + for the behavior, not a preference; dual-mono stereo carries zero information the mono + file lacks. **New captures only, never retroactive** — existing bank entries and files + are untouched. +- **The file and the index value are written together — the stated hazard.** A collapsed + file with `Sample::channelCount` left at 2 still plays (the instrument's + `extractChannel` clamps out-of-range to the last channel, `sample_map.cpp:174-186`, + yielding dual-mono) but the mono/stereo toggle and the waveform lane count would read + Stereo — so the criterion is equality with the file's `fmt` on every path, not absence + of crashes. +- **Insert needs no change, stated so nobody invents work:** `insert.cpp:129-152` passes + only a path to `InsertMedia`; REAPER derives the item's channel count from the file — + a 1-channel WAV yields a mono item for free. +- **Behavior change, stated and accepted:** `hashWavContent` covers the `fmt ` body + + `data` payload, so a collapsed capture will NOT hash-dedup against a pre-existing + stereo twin of the same audio (`bank_model.h:143-148`). Accepted — the predicate is + deterministic, so repeats of the same request still dedup against each other. +- **Bit-identical repeats survive:** a deterministic predicate over deterministic bytes; + identical requests still produce identical files (now identically-collapsed ones). + +**Acceptance criteria.** +- A capture of dead-center mono content yields a 1-channel float32 WAV whose PCM is + bit-identical to either source channel and whose frame count is unchanged — losslessness + asserted by `extractFloatFrames` equality in the pure tests, observed in the DAW on the + real path. `[verify — DAW]` +- A capture with ANY differing sample pair is byte-identical to today's 2-channel output + — the not-collapsed path is unchanged, the same discipline as "bypassed means + byte-identical." +- `Sample::channelCount` equals the produced file's `fmt` channel count on EVERY capture + path — including realtime, whose finalize currently parses the layout and echoes the + request anyway; that defect is fixed here. +- Inserting a collapsed capture yields a mono arrange item; the instrument loads it in + Mono channel mode with a single waveform lane, explicit user toggle still winning. + `[verify — DAW]` +- The null test holds for a collapsed capture re-inserted at its source position. + `[verify — DAW: REAPER's mono-item-on-stereo-track summing at unity is the thing to + confirm]` +- Bit-identical repeats hold across the collapse; realtime and batch behave identically + to offline; ingest is demonstrably untouched (an imported dual-mono file stays stereo). +- The predicate + collapse plan are pure with `wav_codec` test coverage, including the + N-channel all-identical case and the one-sample-differs case. +- Root `CLAUDE.md:208` lands amended, in this track; the `processor_reload.cpp` touch is + the comment + consistency fix only. +- **DAW-verification obligation:** Daniel captures a dead-center source and a true-stereo + source, inserts both, loads both into the instrument, and runs the null test on the + collapsed one. + +**Open questions.** `[propose]` whether bake landings collapse too (lean YES — the bake +"writes a file plus an index entry, like every other capture", `bake_land` already +derives channel count from the file, and a dead-center instrument render is exactly the +dual-mono case; it is `[propose]` rather than decided only because `bake_land` is Ξ-W2's +freshly-landed surface and the collapse there should be confirmed against what actually +shipped). `[verify]` REAPER's mono-item summing for the null test (above). Decided, not +open: unconditional; new-captures-only; ingest excluded; N-generalized predicate; no +partial collapse. + +--- + ## Traceability — all seventeen items The check that nothing was dropped. Every row points at a track that exists above. @@ -2077,6 +2797,11 @@ proof it exists to give. deck-rework entry (whose original "one row of taller decks with within-deck stacking" shape Daniel explicitly superseded), and **Γ-W1-T1 discharges "Raise the stage-time ceiling above 2 s"** (Γ-F3 reversed). +- **All of Phase Ψ** (`ppsi-*`). **Six tracks across two waves**, from a direct list of + seven defects and refinements (Daniel, 2026-08-01), not from `TODO-1.0.md`. Listed here + as a block, like Γ; unlike Γ it has no backing product doc — the seven are recorded + verbatim in the phase header as its provenance (Ψ.1–Ψ.7), and the design content lives + inline in its tracks. - **Γ-W3-T2 `bake-reset-amendment` is a CORRECTION, not a feature**, and belongs on this list for a different reason from the others: it exists only because Ξ-W2-T1 shipped ahead of this plan's sequencing claim. If more corrections of this shape appear, they belong here rather @@ -2176,4 +2901,22 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends (W1-T1 | W1-T3), editor_session.cpp (W2-T1 | W2-T2) — all textual adjacency, not semantic contention. W3-T2's disjointness from W3-T1 is CONDITIONAL: confirm it against what Xi-W2-T1 shipped, and serialize behind T1 if it does not hold. + +Phase Psi — The extension trust pass (none of the seventeen; a direct list of seven) + W1 Exact bounds, disciplined switches, reachable actions, resolved drops [4 tracks] + T1 capture-range-exactness ..... Psi.7 [opens with a DAW repro matrix] + T2 mode-switch-discipline ...... Psi.2 + Psi.3 [one chokepoint: applyMode] + T3 media-explorer-section ...... Psi.4 [BOTH sections; new FOREVER-STABLE id] + T4 drop-target-resolution ...... Psi.5 [gesture law, not a patch] + W2 Names and channels, over the settled render block [gated on W1-T1's render block] + T1 capture-naming .............. Psi.1 [+ the card shows the name] + T2 mono-collapse ............... Psi.6 [lossless only; ingest excluded] + + Three invariant amendments are track deliverables: never-touch-solo (W1-T2, three + files), the action-registration contract (W1-T3, root CLAUDE.md), channel-count- + preserved (W2-T2, root CLAUDE.md:208). + Shared files, named: panel_input.cpp (W1-T2 footer block | W1-T4 drag-arm block); + capture.cpp + capture_realtime_finalize.cpp (W2-T1 naming lines | W2-T2 channel + lines) — all textual adjacency, not semantic contention. main.cpp is W1-T3's + exclusively. ```