Merge dev into phase-g: Phase Ε/Ρ and the 1.5.0 bump meet Phase Gamma's instrument work; 120/120 green

The per-directory CLAUDE.md count is re-derived at twenty-seven rather than
carried from either side. The "Decouple the instrument reload from VST3
activation" TODO entry does not survive: Γ-W3-T1 landed it, and COMPLETED.md
carries the discharge.
This commit is contained in:
2026-08-02 21:57:47 -04:00
139 changed files with 10870 additions and 1993 deletions
+299 -4
View File
@@ -854,9 +854,28 @@ own refs table. Pre-existing — `bakeWindowNeedsHold` is only a new *consumer*
Seven tracks across three waves, code-complete, reviewed, remediated, and integrated on
this branch: 89/89 tests passing, a clean build. Phase Ψ came from a direct list of
seven defects and refinements (Daniel, 2026-08-01) rather than a backing product doc
see `docs/PLAN.md`'s Phase Ψ section for the Ψ.1–Ψ.7 provenance list this phase traces
back to.
seven defects and refinements (Daniel, 2026-08-01) rather than a backing product doc, so
the list is carried here verbatim rather than by reference, now that `docs/PLAN.md`'s
Phase Ψ section (its original home) is retired:
> **Ψ.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.
**Ψ-W1-T1 — `capture-range-exactness`.** A ranged item capture now renders the
requested window instead of the whole item, by re-sourcing through the selected-tracks
@@ -920,7 +939,7 @@ has been confirmed in a running REAPER. Several rest on a **shared unverified
inference** about how REAPER's selected-tracks render source interacts with custom
time bounds — and Ψ-W3's refusal now rests on it too, meaning if the inference is
wrong that refusal costs a working capture. Each track's DAW-verification obligation
is recorded in `docs/PLAN.md`'s Phase Ψ section; `docs/verify-track-scope-multitrack.md`
is recorded in `docs/VERIFICATION.md`; `docs/verify-track-scope-multitrack.md`
is a new standalone verification script on this branch, for Ψ-W3-T1's multi-track
refusal specifically. No human has observed any of these seven behaviors in a DAW.
@@ -1365,3 +1384,279 @@ formatted string, whether REAPER's MIDI learn actually covers the un-shipped `IM
case, the three migration round trips (a pre-parameter project, a save/reopen in an older
binary, automation drawn and replayed), whether an offline render replays automation, and
whether REAPER restores instance state through `setState` rather than `setComponentState`.
### Phase Ρ — Render in place: a track's output to a new sibling, source to the bench
One wave, one track (Ρ-W1-T1 `render-in-place`), code-complete, reviewed, remediated, and
merged to `dev` as `b400384`: 91/91 tests passing, a clean build. Phase Ρ came from a
direct request (Daniel, 2026-08-02) rather than a backing product doc list — see
`docs/product/render-in-place.md` for the framing and its three [Daniel]-class forks
(Ρ-F1/F2/F3), all ruled the day the phase was framed.
**Ρ-W1-T1 — `render-in-place`.** One bindable action, `RENDER_TRACK_IN_PLACE`, renders
the selected track's output over the current range to the project's recording path —
never the bank — places it as an item on a brand-new sibling track at the exact unsnapped
render position, clones the source's colour and its name with an idempotent `Capture `
prefix, moves the source track to Design mode, and puts the result track into Arrange
unconditionally (the Ρ-F2 ruling). New `src/shell/capture/render_in_place.{h,cpp}`.
Extended `core/capture/track_topology` (`siblingPlacement`), `core/capture/capture_name`
(`captureTrackName`), `core/capture/capture_paths` (`RenderPaths`/`deriveRenderPaths`,
with `deriveBankPaths` re-expressed over it). A `CaptureDestination` enum was added to
`CaptureRequest`; `render_bounds_gate` became destination-aware. A filter added to
`panel_input::detectNewContent`, one `ActionTableRow` in `src/app/main.cpp`. All four
invariant amendments the plan required (`src/shell/capture/CLAUDE.md`,
`src/shell/actions/CLAUDE.md`, root `CLAUDE.md` §"The load-bearing principle",
`src/core/view/CLAUDE.md`) landed inline with the track.
**Four deviations worth recording:**
1. **The `activeModeId` acceptance criterion was met in spirit, not to the letter.** The
criterion said `activeModeId()` must appear only in the `applyMode` reapply. The
implementer added the spec-recommended one-line Design-fired `ShowConsoleMsg`, which
requires reading the active mode, and hoisted that read into a single named local
shared by the message condition and the reapply. All three `tag()` calls still take
literal mode ids, so the ruling the criterion protects (Ρ-F2) holds. Review accepted
this explicitly.
2. **The `panel_input` edit was larger than the spec's estimate** — the spec budgeted
"two lines only"; the landed change is six lines plus an `<algorithm>` include and
dropping a `const`, still confined to `detectNewContent`.
3. **`TrackList_AdjustWindows(false)` was included preemptively** where the spec had
asked to `[verify — DAW]` whether it is needed. Consequence worth recording: the DAW
check can no longer distinguish, so answering that question now requires commenting
the call out locally.
4. **A behavioural change beyond Ρ's stated scope**, surfaced in review and judged an
improvement: a track restored by undo now keeps its original mode instead of being
re-tagged to the active mode. Its reach is narrower than it sounds —
`ViewModeModel::reconcile` prunes records for GUIDs that have gone away, so a track
absent across a reconcile pass still falls back to the old behaviour.
Both **[propose at review]** items resolved to the plan's own recommendations: the
Design-fired console message was added (yes), and no master-track refusal was added
(no — `ResolveScopeSource` already refuses a master-only selection).
**The entire DAW-verification obligation remains outstanding.** The null test on Ρ's
own output, the three folder cases, collapsed-mono placement and summing, both mode
transitions waited out past a panel timer tick, undo, name/colour clone, and
`GetProjectPathEx` against a non-default recording path — none of it is unit-testable
and none has been run.
### The offline-render millisecond floor — located and closed (ad-hoc)
Closes the `docs/TODO.md` entry of the same name. REAPER's offline render was
intermittently refusing an otherwise-valid capture whenever the requested window's end
carried a sub-millisecond remainder — breaking root `CLAUDE.md`'s "exact bounds — no
rounding of the requested range" precision invariant.
**Located, not inferred: the floor lives in the `RENDER_BOUNDSFLAG=0` custom-time-bounds
field, not in REAPER's render engine.** Switching the offline render to
`RENDER_BOUNDSFLAG=2` (the project's own time selection, driven through
`GetSet_LoopTimeRange`) escapes it entirely. Confirmed by two live 48 kHz
`TailMode::None` DAW renders, both landing exactly 97627 frames against the window's own
count: the first started at the on-grid `0s` and tested only the END edge (a floored end
would have printed 97584 — 43 frames short); the second, the decisive run, started at
`2.0338983050847457s` and ended at `4.0677966101694913s`, both edges off the
millisecond grid, and no millisecond-floored model of either edge alone or both together
reproduces 97627. No compensation, trimming, or extraction was needed.
`src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection` is now the one
narrative home for the mechanism and the measurement; time selection is the only bounds
mode the offline render reaches.
**Two hypotheses this track's originating entry previously carried are disproven, not
merely superseded** — both predicted a shortfall tracking the render's CONTENT: that the
render bounds itself to the media it can see, and that a trailing-silence trim fires
despite `RENDER_NORMALIZE`. The measured cause tracks the WINDOW instead — the exact
millisecond-floored count, independent of what the material does. Recorded so neither is
re-proposed without a fresh observation.
**Scaffolding removed.** The experiment's apparatus — a two-position
`RenderBoundsChannel` type, a console verdict line, and a three-checkpoint
`RENDER_STARTPOS`/`RENDER_ENDPOS` read-back probe — is deleted now that the mechanism is
settled.
**Left open, filed to `docs/TODO.md`:** `renderHonoredBounds`'s one-frame tolerance
remains empirical, not proven; and `TailMode::Auto`/`Manual` have no automatic bounds
observation at all — `render_bounds_gate` judges `TailMode::None` only, so both modes are
fixed by inference (same bounds path, same floor) rather than by measurement, and only
the 0-byte gate covers them until a DAW check closes it.
### Ε-W1 — The contract, the filesystem, and the ledger's new kind
Phase Ε's first wave: the `.rsbank` package contract, the filesystem/dialog seam
behind it, and a new tracking-ledger origin kind for package-sourced files — three
tracks, disjoint by directory, dispatched in parallel.
**Ε-W1-T1 — `package-format`.** The pure `src/core/package/` codec for the
hand-rolled `RSBK` container (Ε-F1, ruled — no ZIP, no compressor, no link edge to
`vendor/WDL/WDL/zlib/`): a fixed little-endian header carrying two version
integers — `formatVersion` (what the writer emitted) and `minReaderVersion` (the
oldest reader that can read it safely) — a length-prefixed JSON manifest, and
payloads concatenated in manifest order. `classifyPackageVersion` answers
`Readable`/`TooNew`/`Malformed`; a `TooNew` header refuses whole, producing no
manifest, so the refusal can still name the writer's semver rather than
half-succeeding. Landed as three modules: `package_format` (the contract, the
version ladder, and three name-validation rules — `isValidEntryName`,
`sameEntryName`'s ASCII-case fold, `isValidNestedSamplePath`), `package_manifest`
(the manifest model + JSON codec, carrying the bank's `slot_map` and a whole-file
`hashBytes` digest per entry — deliberately not `hashWavContent`, which skips
chunks and so cannot answer "did these bytes survive"), and `bank_package`
(framing/layout arithmetic: `encodePackage`/`decodePackage`/`requiredPrefixSize`,
never holding or hashing a payload itself). Hostile input is refused, never UB,
at every byte offset.
**Ε-W1-T2 — `package-fs-shell`.** `src/shell/package/`: streaming, atomic package
filesystem I/O (`package_io`'s `PackageFileWriter`/`PackageFileReader`, at most one
entry's payload materialized at a time, backed by a `.rsbanktmp` sibling that
reaches the destination only through a `commit()` rename — process-crash atomic,
not power-loss atomic, deliberately, since an `fsync` over a whole sample bank is a
real stall) and the rollback journal (`package_rollback`'s `LandedFileJournal`,
citing the `prune_fs.cpp` carve-out rather than restating it, disarmed only after
the caller's own write has returned success). `package_pickers` rides REAPER's own
`GetUserFileName` for both directions, as specified (mode 1 import, mode 0 export)
— the plan's "REAPER has no save picker" finding was a regex miss in the original
research, not a real gap, so there was no asymmetric-picker deviation to land: no
SWELL `BrowseForSaveFile`, no Win32 `GetSaveFileNameW`, no `GetUserFileNameForRead`
(the SDK header marks it superseded). REAPER owning the dialog on every platform is
why there's no platform split; that's separate from `main.cpp` already aborting
extension load if any needed API pointer fails to resolve, which is why no fallback
path is needed. Both pickers are `[verify — DAW]`, never exercised in a live REAPER
session.
**Ε-W1-T3 — `import-origin-kind`.** `OriginKind::PackageImport` appended to the
tracking ledger as value 5 — package-sourced vs `Ingest`'s user-picked. Append-only,
per `core/tracking/CLAUDE.md`'s persisted-integer rule; an unrecognized kind
degrades to `Unknown` rather than failing the parse, and `kLedgerVersion` stays at
2 — a vocabulary addition, not a document-version bump. No decision surface
changed: `pruneProtection`'s output is unaffected for every existing kind.
### Ε-W2 — The two verbs
Two tracks landed on Ε-W1's contract: a bank leaves the project as one `.rsbank`
file, or the export refuses and says why; a `.rsbank` becomes a **new** bank,
completely or not at all. Both tracks were code-reviewed and remediated before
merging; the merged tree (Ε-W1 + Ε-W2) builds clean and passes 100/100 tests.
**Ε-W2-T1 — `bank-export`.** New `core/package/export_plan` (pure: which entries,
what names, what is missing, and therefore whether the export may proceed — verdict
`Ready`/`Incomplete`/`Refused`) and `shell/package/export_bank` (the promptless
verb, in three composable public steps — `surveyBankExport`, `digestSources`,
`writePackageFile` — arriving with a **const** `ReaSamplerSession&`, so "writes no
ext state, opens no undo point, never bumps the generation" holds by the type
rather than by memory), plus `shell/actions/package_export_action`, one
`main.cpp` action-table row, and one panel bank-menu row. Nothing is re-encoded;
payloads are copied and hashed. The exported unit is one bank — the pool included,
since the pool is structurally one `BankIndex` among many — and whole-book export
stays out of scope for the phase. Both open questions were answered at review:
affordance ships as **both** the bindable action and the panel row, and the
default file name derives from the bank's display name through
`capture_paths::sanitizeStem`.
**Ε-W2-T2 — `bank-import`.** New `core/package/import_plan` (pure: the id remap
table, the parent remap, the per-entry land/skip-already-present/rename
disposition, and the destination bank's display name after `BankBook`'s own
uniqueness fold — reached through a new additive `BankBook::uniqueDisplayName`
member, the only `core/model/` edit in the phase), and on the shell side a
REAPER-free `import_landing` (decode, verify every payload's `hashBytes` digest
against the manifest BEFORE the bank folder is created, then land through the
rollback journal) plus a REAPER-facing `import_bank` (the only piece touching the
extension's project state — the undo-batched persist and the generation bump),
`shell/actions/package_import_action`, the panel's `.rsbank` drop route, one
`main.cpp` row, one panel menu row, and a new `src/core/util/ascii_ws.h`. The
tracking-ledger guard runs before the file picker opens (Ε-F3, ruled: refuse
outright on `Unreadable`/`FutureVersion`, no confirm-and-proceed); the version
gate runs before any byte is written; all four collision classes — sample id, file
name, content hash, bank display name — are answered explicitly, with the
display-name collision auto-suffixed and never prompted (Ε-F2, ruled: always a new
bank, never a merge); birth records land via
`recordCreated(sample, OriginKind::PackageImport)` in the same straight-line block
as the index add; the index mutation is one Ctrl-Z, and the landed files'
survival as orphans until the next prune is stated in the user-facing summary, not
left implicit. **Beyond spec:** `import_plan`'s `spelledLikeABankFile` mints a
fresh name even absent a collision, whenever the package's own entry name isn't
spelled the way `deriveBankPaths` would spell it — counted separately from a
genuine folder-name collision (`sanitizeRenameCount` vs `collisionRenameCount`) so
a hostile or foreign-spelled entry name (e.g. an unexpected extension) always
lands sanitized rather than verbatim.
### Ε-W3 — The compatibility fixtures
The phase's third and final wave, and with it Phase Ε's implementation is complete: the
version-compatibility policy stated in `docs/product/bank-package.md` is now a property
proven against frozen bytes rather than an assertion in a doc.
**Ε-W3-T1 — `package-compat-fixtures`.** A new checked-in corpus of 23 frozen `.rsbank`
fixtures under `tests/fixtures/package_compat/` — one v1 package written by the shipping
build (`1.4.0`), a synthetic additive-forward package (`formatVersion` 2 /
`minReaderVersion` 1) carrying three keys this build has never heard of, a synthetic
structural-refusal package (2/2), nine truncations (one per distinct decode failure
site, including one cut at `additive_forward.rsbank`'s own payload boundary), and eleven
hostile-name packages (six bad entry names, five bad nested `relativePath` values) —
every payload a single 300-byte 16-bit mono WAV, ~15 KB for the whole corpus. Two new
test targets decode and exercise it: `package_compat_tests` (frozen bytes decode to
exactly what the shipping build wrote, the additive fixture reads with every unknown key
skipped, every truncation classifies `Malformed` and never `TooNew`, every hostile name
is refused before any planner runs) and `package_round_trip_tests` (the same corpus
driven through the actual verbs — export → import → export over `v1_shipping.rsbank`
yields byte-identical payloads, and every refusal fixture refuses the whole import with
nothing landed and nothing in the index). A new repo-root `.gitattributes` (`*.rsbank
binary`) is load-bearing, not decoration: under `core.autocrlf = true`, git's NUL-sniffing
heuristic would text-classify a future short, ASCII-heavy fixture and CRLF-mangle it on a
Windows checkout, silently breaking the frozen-bytes premise the whole corpus rests on. A
standalone DAW verification script, `docs/verify-package-transfer.md`, covers the one
claim no unit test can make — a real cross-machine transfer, including the too-new
refusal, the truncated-download refusal, and mid-payload corruption, each read off as an
exact message string. **Open question resolved:** the recommendation (one-sample
packages, a few hundred bytes of payload each) was followed — the corpus holds
one-sample packages with a 300-byte payload each. **Deviation from spec:** the plan
called for a truncation cut mid-layout; RSBK stores no layout section (the layout is
derived from the manifest's entries, not stored as its own section), so the fixture that
exercises "the manifest parses, the layout computes, the exact-size proof fails" lands at
the payload boundary instead. No production module was touched — the wave adds test-tree
files, the corpus, its README, the verification script, and one path variable in the root
`CMakeLists.txt`.
### Resample-bake mono collapse — closes the `docs/TODO.md` deferral (ad-hoc)
`prepareLanding` (`src/shell/capture/bake_landing.cpp`) now applies the shared lossless
mono collapse to the staged buffer — via a new thin wrapper `applyMonoCollapse` in
`src/core/capture/wav_codec.cpp` — before the hash and the channel-count read, so the
hash, the entry, and the written file all come from one collapsed buffer. A dead-center
(dual-mono) bake now lands as a 1-channel file exactly as a dead-center offline capture
already does; a true-stereo bake is byte-identical to before, asserted on bytes and on
hash.
**The blocker this deferral originally cited has cleared.** `bake_land.cpp` was
another team's freshly-landed remediation surface at the time; that remediation has
since landed, which is what made taking this item this wave safe.
**Consequences accepted, not avoided:**
- A dead-center bake's **content identity moves** — the hash now covers the collapsed
bytes, so a dual-mono bake will not hash-dedup against a stereo twin already in the
bank, and its derived file name changes. This was already documented as accepted for
the other capture paths in `src/core/capture/CLAUDE.md`; the bake path now inherits
it rather than being an exception.
- `BakeOutcome::channelCount` now answers 1 for a dead-center bake, which flips the
instrument's channel-mode auto-default to Mono. Safe: the audio is identical either
way when the source was dead-center, and the consuming site was already written
anticipating that value.
### Design View FX-GUID keying for `restoreFxOffline` — closes the `docs/TODO.md` deferral (ad-hoc)
`restoreFxOffline` (`src/shell/view/view.cpp`) now returns each parked track's per-FX
offline state to the plugin it was captured from, keyed by the FX's own GUID
(`TrackFX_GetFXGUID`) rather than its slot index. New pure module
`src/core/view/fx_offline.{h,cpp}` holds the keying types (`FxKeying`: Identity/Slot),
the per-FX snapshot/plan types, and `resolveFxRestore`, which matches each captured
state against the chain as it stands at restore time. `view_state` gained a v2 schema
that writes the identity array beside the v1 slot array, so an older build reading a
v2 blob keeps the behaviour it had rather than losing every FX state.
**Consequences accepted, not avoided:**
- A dropped FX (one whose captured identity is no longer live in the chain at restore
time) is left **offline**, as park left it, with its snapshot already cleared — the
console report names the drop and the recovery.
- The console report uses the quiet `!SHOW:` form on every path, so it never
force-opens the console window.
**Left open, filed to `docs/TODO.md`:** FX-GUID stability itself — whether
`TrackFX_GetFXGUID` survives a chain reorder while parked — is unverified in the DAW
(SWS issue #802 names a specific way it might not hold).