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:
+299
-4
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user