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
+1
View File
@@ -0,0 +1 @@
*.rsbank binary
+7 -4
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
Per-module detail — what each file owns, its invariants — lives in the twenty-four per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
Per-module detail — what each file owns, its invariants — lives in the twenty-seven per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
## Settled decisions
@@ -84,7 +84,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
## Architecture: the load-bearing split
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-four directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-seven directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
| Directory | Scope |
|---|---|
@@ -94,10 +94,12 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
| `src/core/instrument/` | pure VST3-instrument core (bake / engine / map / note / param / ui) |
| `src/core/instrument/bake/` | the resample bake's pure half — the programmed note resolved to a frame window, the offline render over a bake-only voice engine, and the post-bake reset |
| `src/core/instrument/engine/filter/` | pure per-voice resonant TPT/SVF filter (HP→BP→LP / HP→notch→LP morph, drive stage), run by each `Voice` between the pitch and amp stages |
| `src/core/instrument/engine/loop/` | the sustain loop's ONE validity/clamp fold plus its pre-seam crossfade geometry and the editor's default handle span |
| `src/core/instrument/note/` | the programmed capture-signal model — musical divisions, tempo resolution, anchored offsets |
| `src/core/instrument/param/` | the VST3 parameter surface's pure half — the FOREVER-FROZEN id table, the exposed set derived from the commit predicate, the plain-value layer, and the one formatter per unit category |
| `src/core/json/` | the hand-rolled JSON lexical layer |
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
| `src/core/package/` | the pure RSBK bank-package codec — format contract, version ladder, JSON manifest, framing/layout codec |
| `src/core/reclaim/` | pure prune orphan computation |
| `src/core/tracking/` | the consolidated file-tracking system — birth/lineage records and the one authority answering prune's protected set and the resample's replace-vs-add |
| `src/core/ui/` | pure UI geometry, palette, and interaction-decision modules |
@@ -109,6 +111,7 @@ There is no hot-reload. Copy the **Release** build's binary (`build/Release/` on
| `src/shell/bank_ops/` | promptless bank-mutation verbs |
| `src/shell/capture/` | REAPER-facing capture backends and action bodies |
| `src/shell/instrument/` | ReaSampler 9000 VST3 shells |
| `src/shell/package/` | package filesystem I/O (streaming atomic read/write, exclusive-create landing), the rollback journal, and the REAPER file pickers |
| `src/shell/panel/` | the docked bank-panel shell + the shared LICE draw kit |
| `src/shell/persist/` | project ext-state persistence, prune filesystem I/O, usage scan |
| `src/shell/view/` | Design View mode application shell |
@@ -190,7 +193,7 @@ Comments carry *why*, and context where non-obvious — never *what* the code al
`docs/product/` holds the product-design reasoning behind each phase — the "why we chose this" that predates the spec. They are large; **grep for the cited section rather than reading a file whole**. `docs/cmake-cheatsheet.md` is a standalone build-system reference.
Files: `capture-tail.md`, `code-organization.md`, `design-view.md`, `midi-playback.md`, `multi-bank.md`, `provenance.md`, `removal-and-prune.md`, `versioning-and-release.md`, `visual-design-language.md`.
Files: `bank-package.md`, `capture-tail.md`, `code-organization.md`, `code-quality-audit.md`, `design-view.md`, `instrument-control-surface.md`, `linux-readiness.md`, `midi-playback.md`, `multi-bank.md`, `parameter-automation.md`, `provenance.md`, `removal-and-prune.md`, `render-in-place.md`, `versioning-and-release.md`, `visual-design-language.md`. `audit-notes/` is a subdirectory of track-evidence appendices for `code-quality-audit.md`, not a peer doc — not enumerated above.
## Project docs
@@ -202,7 +205,7 @@ Plan-style docs live under `docs/`:
## The load-bearing principle
**Capture and placement are separate acts.** Capturing audio writes a file to the bank and adds an index entry. It **never** puts an item in the arrange view. Placement is a distinct, on-demand action (`insert` module / `InsertMedia`). Any code path that auto-inserts a capture into the timeline violates the purpose of the tool and **must be rejected in review**.
**Capture and placement are separate acts.** Capturing audio writes a file to the bank and adds an index entry. It **never** puts an item in the arrange view. Placement is a distinct, on-demand action (`insert` module / `InsertMedia`). Any code path that auto-inserts a capture into the timeline violates the purpose of the tool and **must be rejected in review**. A render that goes arrange → arrange, never entering the bank and never reading it (`shell/capture/render_in_place`), is a THIRD verb outside this rule rather than a softening of it — the rule binds anything that touches the bank on either side, so a bank sample may still only reach the timeline through an on-demand placement, and a capture may never grow a place step.
## Precision invariants — required before any feature ships
+4 -2
View File
@@ -21,9 +21,9 @@ cmake_minimum_required(VERSION 3.19)
# invariant (reconstruct-from-components inside app_version.cpp) via a permanent synthetic
# "0.9.01" fixture there that must NEVER be bumped on release. It cannot see this line
# becoming a CMake derivation — that is this comment's job.
set(REASAMPLER_VERSION "1.4.0")
set(REASAMPLER_VERSION "1.5.0")
project(reaper_reasampler VERSION 1.4.0 LANGUAGES CXX)
project(reaper_reasampler VERSION 1.5.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -85,6 +85,7 @@ set(LICE_SRC
# ---------------------------------------------------------------------------
set(REASAMPLER_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src)
set(REASAMPLER_TESTS_DIR ${CMAKE_CURRENT_SOURCE_DIR}/tests)
set(REASAMPLER_PACKAGE_FIXTURE_DIR ${REASAMPLER_TESTS_DIR}/fixtures/package_compat)
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/reasampler_targets.cmake)
enable_testing()
@@ -92,3 +93,4 @@ enable_testing()
add_subdirectory(src/core)
add_subdirectory(src/app)
add_subdirectory(src/shell/instrument)
add_subdirectory(src/shell/package)
+1 -1
View File
@@ -1,6 +1,6 @@
# ReaSampler
Version 1.4.0 · License: GNU AGPL v3 (see `LICENSE`)
Version 1.5.0 · License: GNU AGPL v3 (see `LICENSE`)
A per-project audio sample-bank capture tool for REAPER, built as two artifacts: a
native C++ REAPER extension (`reaper_reasampler`) and a Windows-only VST3 sampler
+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).
+32 -1527
View File
File diff suppressed because it is too large Load Diff
+171 -87
View File
@@ -110,20 +110,6 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
**Done looks like.** Not stated in PLAN.md.
## FX-GUID keying for `restoreFxOffline` (Design View park/restore)
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section): the bulk of reconcile residuals shipped (`ViewModeModel::reconcile(liveGuids)` prunes orphaned snapshots on every toggle/load; folder restructure is self-healing because the tree is rebuilt each toggle; membership is intentionally kept so undo-delete preserves the tag). Two sub-items were left deferred out of that; this is the first.
**The wart.** `restoreFxOffline` currently restores per-FX offline state by slot index. If the FX chain is reshuffled while a track is parked, restore lands on whatever plugin now occupies that slot rather than the plugin it was originally captured from.
**Intended fix.** FX-GUID keying — key the per-FX offline snapshot entries by FX identity rather than slot index.
**The constraint the fix MUST handle.** The keying change requires a snapshot-schema migration; CONTEXT.md names this alongside the keying change as the reason the fix was deferred rather than folded into the reconcile-residuals work.
**Priority / risk.** Not stated in the source.
**Done looks like.** Not stated in the source beyond the fix description above.
## Dormant membership entries in persisted `view_state`
**Context.** CONTEXT.md's "Open questions to resolve during build" (Design View section), the second of the two sub-items left deferred after the reconcile-residuals ship described above.
@@ -318,6 +304,15 @@ buffer.
shared with every other caller in `core/capture/wav_codec`; a fix must not change
those callers' contract or add a second WAV-building code path to maintain.
**Re-confirmed still accurate (2026-08-02), after the mono-collapse landing touched
`wav_codec` adjacent to this site.** `applyMonoCollapse` operates on the staged bytes in
`bake_landing.cpp`'s `prepareLanding`, upstream of and unrelated to `runBake`'s
`std::vector<double>` copy in `instrument_bake.cpp`; `buildFloat32Wav`'s signature is
unchanged. The wart stands exactly as described above.
**Current blocker.** Not taken this wave because `instrument_bake.cpp` is being edited
by a live VST3-parameter track.
**Priority / risk.** Low / deferred. Logged at Ξ-W2-T1's review; correctness is
unaffected, only peak memory on a large bake.
@@ -507,7 +502,7 @@ needs no live REAPER process to exercise `rec->Register(...)` calls. Once
**The constraint the fix MUST handle.** The extraction alone buys nothing:
`action_registry` has no test target today either, so lifting `ingestHandleSectionCommand`
into it without also standing up the test target just relocates the untested code. The
same follow-up could collapse `ingest.cpp:466-472`'s hand-rolled `command_id`+`gaccel`
same follow-up could collapse `ingest.cpp`'s `ingestRegisterActions` hand-rolled `command_id`+`gaccel`
pair onto `action_registry::registerAction`, which already does exactly that dance for
the Q-W6 table.
@@ -542,7 +537,7 @@ track", nothing to do and the inference is retired into fact. If it comes back "
summed file", the refusal is over-strict for the TRACK scope and should be narrowed back
— and the ITEM-scope half is then an OPEN question, not settled: a full-extent item
capture already sums a multi-track item selection via `&32|single-file`
(`tests/test_render_settings.cpp:262`), so if `&128` also sums, a ranged item capture
(`test_render_settings.cpp`'s `testMultiTrackStemRenderIsNamedForRefusal`), so if `&128` also sums, a ranged item capture
routed through it sums too, and keeping the item refusal in that branch would make item
scope inconsistent with itself across the range boundary (full-extent sums, ranged
refuses, same scope). Whether that inconsistency is acceptable or the item refusal should
@@ -622,44 +617,6 @@ select/move the neighbour, or capture at track scope instead.
non-isolation as an oversight and re-propose closing it against the recipe's stated
tracks-and-range-only shape.
## Resample-bake landings don't apply the lossless mono collapse to a dual-mono render
**Context (surfaced by Ψ-W2-T2, mono-collapse).** The collapse (`collapseCapturedFileToMono`
/ `core/capture/wav_codec::collapseToMono`) ships for every extension capture path —
offline, realtime, batch, recapture — but not for `bake_land.cpp`'s landing, the
resample bake's `prepareLanding` / `commitLanding` pair. A dead-center instrument render (the common case
that motivated Ψ.6 in the first place) is exactly the dual-mono shape the predicate
collapses, so an un-collapsed bake keeps paying for the second channel it doesn't need.
**Not deferred for the reason once given.** `prepareLanding` reads the staged file into
`prep.bytes` once, parses its layout, hashes it and derives the channel count from that
same one buffer, and `commitLanding` writes that buffer — so collapsing it right after the
layout parse would keep the hash, the channel count, and the written file consistent by
construction; there is no ordering hazard here to defer around.
**The real reason.** `bake_land.cpp` is Phase Ξ's freshly-landed surface
(Ξ-W2-T1, the resample bake chain) and another team is actively remediating it. Landing
a mutation there now would cross tracks mid-remediation for no urgent gain — the mono
propagation this item would add is a size win, not a correctness one.
**A mono capture already propagates through the bake for free**, so this item is scoped
to the dual-mono-*render* case only: `runBake` / `instrument_bake.cpp` already renders
however many channels the dialed sound has, and `bake_render.cpp:38` reads
`sample.channelCount()` off that render rather than hardcoding 2 — a mono-programmed
sound already bakes to a mono file today, with no change needed.
**Intended fix.** Once `bake_land.cpp` is quiet, call `collapseToMono` on `prep.bytes` in
`prepareLanding` right after the layout parse and before the hash, matching the
offline/realtime insertion point (post-parse, pre-identity-read).
**Priority / risk.** Low — a size optimization on an already-correct path, not a
precision-invariant gap; the bake's dual-mono case still lands as a valid (if larger)
stereo file today.
**Done looks like.** A dead-center instrument bake lands as a 1-channel file with
`Sample::channelCount` matching, the same way an offline dead-center capture does; a
true-stereo bake is byte-identical to today's output.
## A 0-byte render can still pass every gate under Auto/Manual tail (closed)
**Context (surfaced by Ψ-W3 review).** `OfflineRenderBackend::capture`'s exists-check
@@ -682,37 +639,106 @@ refusal reuses `CaptureStatus::BoundsMismatch` rather than minting its own statu
earlier note here preferred a distinct status, and that preference is unresolved, not
withdrawn.
## An offline capture can be refused for a short render — root cause open
## `renderHonoredBounds`'s one-frame tolerance is empirical, not proven
**Symptom (live, 2026-08-02).** A capture over [0.000000s, 4.067797s) at 48 kHz was
refused: `Render produced 195216 frames but the requested range is 195254`. 38 frames
short — 38x the gate's one-frame tolerance, so the tolerance is not what refused it.
**Context.** The millisecond-floor defect that motivated this gate is closed
(`docs/COMPLETED.md`), but the gate itself — `render_window.h`'s
`renderHonoredBounds` — carries a one-frame tolerance that carried through the fix
unchanged and was never itself proven.
**Hypothesis A — the render bounds itself to the media it can see.** REAPER's
selected-items render source (`&32`) derives its bounds from the selected items' own
extents (`src/core/capture/CLAUDE.md` §Gotchas — itself an inference from an observed
defect, not a header fact). If a time-bounded selected-tracks render (`&128`) does the
same thing against content extent, a range running past the end of its material comes up
exactly as short as the material is.
**The wart.** A renderer that resolves the window's two edges by DIFFERENT
conventions can sit two frames from `frameCountFor`'s answer on a
correctly-honored render. That cannot account for the 8- and 38-frame shortfalls
the floor produced (`docs/COMPLETED.md`), so it was not the cause of those
refusals — but it means a future one- or two-frame refusal may be the gate's own
edge convention rather than a real defect.
**Hypothesis B — a trailing-silence trim fires anyway.** `TailMode::None` sets
`RENDER_NORMALIZE = &(4<<16)` (disable all postprocessing) and `RENDER_TRIMEND = 0`. If
REAPER trims regardless of that bit, a range whose material decays before its end loses
exactly the decayed frames.
**Intended fix.** Not proposed. Widening the tolerance is a precision-invariant
decision, not a bug fix, and was deliberately not taken on speculation.
**Not excluded — the gate itself.** `renderHonoredBounds`' one-frame tolerance is
empirical, not proven (`src/core/capture/render_window.h`): a renderer that resolves the
window's two edges by DIFFERENT conventions can sit two frames from `frameCountFor`'s
answer on a correctly-honored render. That cannot account for 38 frames, so it is not
this refusal — but it means a future one- or two-frame refusal may be ours, which is why
the tolerance was not widened on speculation. Widening it is a precision-invariant
decision, not a bug fix.
**Priority / risk.** Low. Nothing to date implicates the tolerance itself;
recorded so a future narrow refusal is investigated rather than assumed to be
the same floor.
**How it gets decided.** `docs/VERIFICATION.md` §Capture range and bounds, the three
numbered blocker steps: step 1 separates A's `&32` path from the shared `&128` path (and
says how to tell when it failed to), step 2 asks whether the render is short at all, step
3 reads the retained refused render to place the missing frames. Nothing here should be
"fixed" before that comes back.
**Done looks like.** Either the tolerance is confirmed correct by a DAW
observation that isolates edge-convention behavior from bounds-floor behavior,
or it is widened with the reasoning recorded.
## `TailMode::Auto` and `Manual` have no automatic bounds observation
**Context.** `render_bounds_gate.cpp`'s `checkRenderedBounds` returns early for
anything but `TailMode::None`, so the millisecond-floor fix (`docs/COMPLETED.md`)
was measured only against `TailMode::None` — Auto and Manual were never
observed, before the fix or after it.
**The wart.** The inference that Auto/Manual are fixed too is sound — same
bounds path, same floor, same fix — but it is an inference, not a measurement.
`checkRenderedFileNotEmpty` runs on every tail mode and still catches a 0-byte
render, but that is the ONLY automatic bounds signal Auto/Manual get; a
floored or otherwise short-but-nonzero render under either mode would land as
`Ok` with nothing to catch it.
**Intended fix.** Not a code change — a DAW observation. `docs/VERIFICATION.md`'s
"Capture range and bounds" section already carries the manual check: repeat an
off-grid-start capture at Manual over a source loud to the window's end and
check the landed frame count against window + `tailMs`; Auto can't be checked
by count (it trims trailing silence) and needs the null test by ear/inversion
instead.
**Priority / risk.** Low. Both modes share the same bounds path as the
now-fixed `TailMode::None`, so nothing suggests they still floor — but nothing
confirms it either.
**Done looks like.** A DAW-observed Auto and Manual capture, each landing the
window as requested, closes the inference into fact — or surfaces a
mode-specific divergence this entry does not currently know about.
## Floor, ceil and round are not the identity on a millisecond grid point in binary double (caution, not an open question)
A discarded compensation design for the millisecond-floor defect
(`docs/COMPLETED.md`) rested on the premise that a grid-aligned value survives a
bare floor/ceil/round unchanged. That is false in binary double: `1.007 * 1000
== 1006.9999999999999` (floors to 1006, not 1007), and `4.068 * 1000 ==
4067.9999999999995` (floors to 4067, not 4068). The compensation this premise
would have supported is no longer needed — the fix moved the render to a bounds
mode that does not floor at all — so this is not a live open question. Recorded
because it would bite any future millisecond-grid arithmetic that assumes an
on-grid value is safe from a bare floor: `render_window.h`'s own
`isOnMillisecondGrid`/`msFlooredEndFrameCount` already carry the nanosecond
tolerance that handles it correctly on this codebase's side of the boundary; the
trap is for whoever writes the next piece of grid arithmetic without that guard.
## `capture.cpp` is over the ~600-line ceiling — the seam is identified, taking it is blocked
**Context.** Removing the settled bounds experiment's instrumentation (the console
verdict and the three-checkpoint `RENDER_STARTPOS`/`ENDPOS` read-back) brought the file
from 697 to **620 measured lines**, against root `CLAUDE.md`'s ~600-line ceiling. The
seam that entry originally named is gone with the instrumentation; nothing left in the
file is bisectable without cutting load-bearing why.
**The remaining seam is a real responsibility boundary**, and the file header already
names it as two things: `OfflineRenderBackend::capture` (the offline render driver)
versus the four helpers BOTH backends share — `makeUniqueTag`, `captureNameFor`,
`collapseCapturedFileToMono`, `stampCaptureSample` — consumed by `capture_batch`,
`capture_orchestrator`, `capture_realtime_shell`, `capture_realtime_finalize` and
`render_in_place`. Lifting those four into their own TU takes the driver under the
ceiling and gives the cross-backend steps their own home.
**Why not taken.** `src/shell/capture/` has no `CMakeLists.txt` of its own — its sources
are listed in `src/app/CMakeLists.txt`, so a new TU needs an edit there. Forcing the
four helpers into an existing TU instead (orchestrator, realtime finalize) would put
them in a wrong home to dodge one build-file line, which is worse than the overshoot.
## bext TimeReference read-back is not a floor detector (dead end, recorded so it is not re-litigated)
Idea considered and dropped: read a captured file's `BWF:TimeReference` tag back as
independent evidence on the START-edge millisecond-floor question above. `WDL/metadata.h`'s
`WriteMetadataPrefPos` only writes it past its `prefpos > 0.0` guard (`:1301`) — that guard
alone is enough to rule the approach out. One nuance worth recording separately: the
millisecond quantization at `:1382-1383` (`AddMexMetadata`'s `ParseUInt64(val)/1000.0`)
belongs to the MEX caller, not proven to be `WriteMetadataPrefPos`'s own behavior or the
renderer's direct call into it — so even without the guard, a floored bext tag would show
that MEX quantizes, not that the render engine does.
## Split `render_bounds_gate` on the verdict/message vs. filesystem seam
@@ -744,8 +770,8 @@ the two callers' plumbing.
**Context.** `saveToActiveProject()` returns false for exactly two reasons — no active
project, or an unsaved one — and in both cases NOTHING was written. Four capture sites
discard that return outright: `capture_orchestrator.cpp:343`, `capture_batch.cpp:266` and
`:333`, and `realtime_lifecycle.cpp:39`.
discard that return outright: `capture_orchestrator.cpp`'s `RunCapture`, `capture_batch.cpp`'s
`RunBatchCaptureItems` and `RunBatchCaptureRazor`, and `realtime_lifecycle.cpp`'s `CommitRealtimeResult`.
**The wart.** A capture on an unsaved project renders the file into the bank folder, adds
the `Sample` to the in-memory book, records a birth record in memory — and loses all three
@@ -766,7 +792,7 @@ and the choice between those two is recorded rather than implicit.
## `panel_input`'s wheel handler persists the whole book per wheel message
**Context.** `panel_input.cpp:450``handleWheel` calls `markTailDirty()` on every wheel
**Context.** `panel_input.cpp``handleWheel` calls `markTailDirty()` on every wheel
message that actually moves `manualMs`, while the pointer is over the footer in Manual
mode. (It coalesces sub-notch deltas within ONE message and no-ops at a bound, so the
count is wheel messages that changed the value, not raw notches.)
@@ -785,10 +811,10 @@ the value that lands is the gesture's final one.
## `RunCaptureItemAssign`'s undo point does not follow the pattern its comment claims
**Context.** `capture_orchestrator.cpp:364-365` states that the action follows the bank-op
family's discard-on-unsaved pattern.
**Context.** `capture_orchestrator.cpp`'s `RunCaptureItemAssign` states that the action follows
the bank-op family's discard-on-unsaved pattern.
**The wart.** It does not: `:382-383` records the undo point unconditionally whenever
**The wart.** It does not: `RunCaptureItemAssign` records the undo point unconditionally whenever
`sampleId` is non-empty, and never consults the persist's return at all. So on an unsaved
project it records an undo point for ext-state that was never written — the empty
no-effect entry `persistBankOp`'s guardrail exists to avoid. The comment describes the
@@ -803,7 +829,8 @@ the unsaved-project case one way.
## `core/tracking/CLAUDE.md`'s untracked-file enumeration says "reaches the `.rpp`" too loosely
**Context.** `src/core/tracking/CLAUDE.md:24-31` enumerates how a created file can stay
**Context.** `src/core/tracking/CLAUDE.md` §"Invariants" — "No silent gaps — in memory at
creation, on disk at the next save" — enumerates how a created file can stay
untracked, and describes the ledger as reaching the `.rpp` at the following
`saveToActiveProject()`.
@@ -837,3 +864,60 @@ in the `.rpp`", and does not gain a second home for the distinction.
**Priority / risk.** Low. Nothing here is load-bearing on the frozen contract: the id table, the plain ranges and the norm↔plain laws are all decided and tested without a host.
**Done looks like.** Each of the four exercised once in REAPER, with the unit-rendering answer recorded and, if it went the other way, the one-line formatter change made.
## `view_mode_model.cpp` is over the ~600-line structural bar, and `view.cpp` is close behind
**Context (surfaced by the FX-GUID keying track).** Root `CLAUDE.md`'s structural
heuristics put an ~600-line ceiling on any one file, with a documented responsibility
seam as the required method for splitting it, not an arbitrary bisection.
`src/core/view/view_mode_model.cpp` measures **815 lines** (verified this pass),
up from 715 before the FX-GUID keying track's v2 schema addition made it worse.
**The named seam.** The JSON codec — `serialize()`/`deserialize()` — wants its own
`view_state_codec` TU in `src/core/view/`.
**Why it was deferred, and this reasoning should survive.** `serialize()` is a
`ViewModeModel` member and `deserialize()` a static factory (confirmed:
`std::string ViewModeModel::serialize() const` and
`std::optional<ViewModeModel> ViewModeModel::deserialize(const std::string&)`), both
reaching private state — so extraction needs either a friend declaration or a new
public accessor surface. Doing that in the same commit that changed the byte format
the golden test literals pin would roll a format change and a codec extraction
together, which is the riskier order.
**Also over the bar, blocked differently.** `src/shell/view/view.cpp` measures
**642 lines** (verified this pass). Its seam is blocked not by a private-state/friend
question but by a build file another team owns: `src/shell/view/` has no
`CMakeLists.txt` of its own today.
**Priority / risk.** Not stated.
**Done looks like.** `view_mode_model.cpp`'s JSON codec is extracted into its own
`view_state_codec` TU (with the friend/accessor question resolved deliberately, not
sidestepped), dropping the file under the ~600-line ceiling; `view.cpp`'s own path is
unblocked once the build-file ownership question is resolved.
## FX-GUID stability for `restoreFxOffline` is unverified in the DAW
**Context.** The Design View park/restore FX keying (`restoreFxOffline`,
`src/shell/view/view.cpp`) rests on `TrackFX_GetFXGUID` returning an identity that
survives a chain reorder while a track is parked. SWS issue #802 reports that after
`SNM_MoveOrRemoveTrackFX` reorders a chain, the FXID lines do not follow the plugin
(`SNM_PreObjectState()``RemoveAllIds()`) — if that still holds, an SWS-driven
reorder while parked produces wrong-plugin restores or mass drops, which is the exact
operation this keying targets.
**What must be checked.** Native drag-reorder, an SWS move, save/reload, and two live
instances of the same plugin.
**Already flagged in code — this entry is the tracked home, not a restatement.**
There is a `[verify — DAW]` marker at `fxGuidString` in `src/shell/view/view.cpp` and
a note in `src/shell/view/CLAUDE.md`'s Gotchas; point at them rather than restating
them in full.
**Priority / risk.** Not stated.
**Done looks like.** Native reorder, SWS reorder, save/reload, and a
two-instance-of-the-same-plugin case are each observed in a live REAPER session, and
either the identity is confirmed to survive all four, or a degradation is found and
the keying is amended.
+146 -53
View File
@@ -1,83 +1,176 @@
# DAW verification — post-1.0 work on `dev`
Checks for Θ, Ξ, and Ψ work that no unit test can close. Build **Release**, install into
Checks for Θ, Ξ, Ψ, Ε, and Ρ work that no unit test can close. Build **Release**, install into
`UserPlugins/`, restart REAPER. Panel tail toggle = **None**, project rate 48000, unless a check says otherwise.
## Precision invariants
- [ ] Dry offline item capture of a 2 s range, re-inserted at its source position, inverted against the source — reads silence (`CLAUDE.md:207`)
- [ ] Run the identical offline capture request twice — the two files are byte-identical on disk (`CLAUDE.md:208`)
- [ ] After any capture, source items and tracks are unchanged: fader, pan, mute, FX bypass, selection (`CLAUDE.md:209`)
- [ ] After a realtime capture, the temp track is gone and every source track's routing is back as it was (`CLAUDE.md:209`)
- [ ] Capture 10.00012.000 s — card reads 2.000 s / 96000 frames, no leading or trailing silence (`CLAUDE.md:210`)
- [ ] With an FX on the source track: item scope does NOT carry it, track scope does (`CLAUDE.md:212`)
- [ ] Track scope on a child track with FX, gain, and pan set on the parent and master — neither colors the capture (`CLAUDE.md:212`)
- [ ] Save, move the whole project folder elsewhere, reopen — every card still resolves and auditions (`CLAUDE.md:211`)
- [ ] Dry offline item capture of a 2 s range, re-inserted at its source position, inverted against the source — reads silence (`CLAUDE.md` §"Precision invariants" — "Null test")
- [ ] Run the identical offline capture request twice — the two files are byte-identical on disk (`CLAUDE.md` §"Precision invariants" — "Bit-identical repeats")
- [ ] After any capture, source items and tracks are unchanged: fader, pan, mute, FX bypass, selection (`CLAUDE.md` §"Precision invariants" — "Non-destructive")
- [ ] After a realtime capture, the temp track is gone and every source track's routing is back as it was (`CLAUDE.md` §"Precision invariants" — "Non-destructive")
- [ ] Capture 10.00012.000 s — card reads 2.000 s / 96000 frames, no leading or trailing silence (`CLAUDE.md` §"Precision invariants" — "Exact bounds")
- [ ] With an FX on the source track: item scope does NOT carry it, track scope does (`CLAUDE.md` §"Precision invariants" — "Capture FX scope")
- [ ] Track scope on a child track with FX, gain, and pan set on the parent and master — neither colors the capture (`CLAUDE.md` §"Precision invariants" — "Capture FX scope")
- [ ] Save, move the whole project folder elsewhere, reopen — every card still resolves and auditions (`CLAUDE.md` §"Precision invariants" — "Relative paths only")
## The decisive observation
- [ ] **Run first.** `docs/verify-track-scope-multitrack.md` §3 by hand, and count the files REAPER writes (`docs/TODO.md:552`, `PLAN.md:2134`, `PLAN.md:2238`)
- [ ] Two files confirms Ψ-W1-T1 and Ψ-W3-T1 at once; **one summed file invalidates both** — stop and report, the refusal is costing a capture 1.0.0 accepted (`PLAN.md:2239`, `docs/COMPLETED.md:884`)
- [ ] Then walk the rest of `docs/verify-track-scope-multitrack.md` (§1–§2, §4–§7) for the multi-track refusal itself (`PLAN.md:2240`)
- [ ] **Run first.** `docs/verify-track-scope-multitrack.md` §3 by hand, and count the files REAPER writes (`docs/TODO.md` §"The `&128` multi-track output shape is still DAW-unobserved", `docs/COMPLETED.md` §"Ψ-W3-T1", `docs/COMPLETED.md` §"None of the seven is DAW-verified")
- [ ] Two files confirms Ψ-W1-T1 and Ψ-W3-T1 at once; **one summed file invalidates both** — stop and report, the refusal is costing a capture 1.0.0 accepted (`docs/COMPLETED.md` §"None of the seven is DAW-verified")
- [ ] Then walk the rest of `docs/verify-track-scope-multitrack.md` (§1–§2, §4–§7) for the multi-track refusal itself (`docs/COMPLETED.md` §"Ψ-W3-T1")
## Capture range and bounds
- [ ] Over an item much longer than the selection: item scope × time selection, and item scope × razor — each lands exactly the window, not the whole item (`PLAN.md:2136`)
- [ ] Same source: track scope × time selection, and track scope × razor — same exact window (`PLAN.md:2136`)
- [ ] One razor-union case (two disjoint areas, one track) — lands the requested window, no `ReaSampler capture failed:` line (`PLAN.md:2137`)
- [ ] Capture an item whose extent already equals the window — still lands, unchanged (the byte-identity regression floor) (`docs/COMPLETED.md:829`)
- [ ] **Open blocker — root cause unknown; the three steps below are the experiment** (both live hypotheses and what is NOT yet excluded: `docs/TODO.md` §An offline capture can be refused for a short render). A live capture over [0.000000s, 4.067797s) was refused 38 frames short (195216 of 195254 at 48 kHz). Set View → time unit to Samples first
- [ ] **Step 1 — does the shortfall follow the render source?** This only tests anything if item scope actually reaches REAPER's selected-items render, and it does that ONLY when the selected items' extent already equals the requested window (`itemExtentPrintsWindow`, `src/core/capture/render_window.h`); otherwise item scope re-sources through the items' own tracks — the same source track scope uses, so the two runs would test one thing twice. So: snap the time selection to the item's exact start and end, run **item** scope, then **track** scope over the identical range. Report both `Render source:` lines and both frame counts. **If both lines read `selected tracks via master`, the item path was NOT exercised** — the extents did not match; re-snap and repeat before concluding anything. **A landing `&32` run here is not evidence `&32` honours custom bounds** — at a window snapped to the item's own extent, a render that honours the window and one that bounds itself to media content print IDENTICAL frames, so this step cannot tell those two apart; it only tells you which render source is in play. **If neither run refuses at this snapped range, the blocker did not reproduce here** — this range does not recreate the original refusal, which ran past the end of its media; move to step 2, which does
- [ ] **Step 2 — full-length or short?** Extend the same range ~1 s past the end of all media, **track** scope. Landing with the full range (no refusal, the card reads the extended length) rules out BOTH a trailing-silence trim and a content-extent bound at once — but it also means there is no refused render for step 3 to read; re-run the ORIGINAL refusing range ([0.000000s, 4.067797s), track scope) to produce one before continuing. A short render does NOT tell the two hypotheses apart: a trim firing despite `RENDER_NORMALIZE &(4<<16)` and a render bounding itself to content extent produce the same count — and that render IS the one step 3 reads. Report which happened, then run step 3
- [ ] **Step 3 — where are the missing frames?** Reads the short render from step 2 (or, if step 2 landed, the fresh refused render from re-running the original range per step 2's note) — not anything step 1 may have left behind, since a correctly-snapped step 1 should not have refused at all. A refused render is kept deliberately, not deleted: it is moved to `<project folder>/reasampler_refused/`. **Follow the path in the refusal line, not this sentence** — if the move itself failed the file stays in the bank folder, unindexed, and the line says which happened. Filenames carry a timestamp/counter but no scope marker, so if more than one file has landed in `reasampler_refused/` by now, the one from step 2 is the most recently written one — or empty the folder before running step 2 so there is only one candidate. Insert it against the source over the same range and report whether the head aligns. Frames missing from the TAIL with an aligned head fits either a tail trim or a content-extent bound; a head offset fits neither and is a start-position defect. Also report whether the media under the range ends before the range does. Delete `reasampler_refused/` when done — nothing in the bank references it
- [ ] Over an item much longer than the selection: item scope × time selection, and item scope × razor — each lands exactly the window, not the whole item (`docs/COMPLETED.md` §"Ψ-W1-T1")
- [ ] Same source: track scope × time selection, and track scope × razor — same exact window (`docs/COMPLETED.md` §"Ψ-W1-T1")
- [ ] One razor-union case (two disjoint areas, one track) — lands the requested window, no `ReaSampler capture failed:` line (`docs/COMPLETED.md` §"Ψ-W1-T1")
- [ ] Capture an item whose extent already equals the window — still lands, unchanged (the byte-identity regression floor) (`docs/COMPLETED.md` §"Ψ-W1-T1")
- [ ] **The millisecond floor — SETTLED, nothing to re-run for `TailMode::None`.** The floor lives in the custom-time-bounds field (`RENDER_BOUNDSFLAG=0`), not in the render engine. Two live 48 kHz `TailMode::None` renders on `RENDER_BOUNDSFLAG=2` (time selection, handed over via `GetSet_LoopTimeRange`) came back exact — 97627 frames against 97627 — the second over a window whose START carried a sub-millisecond remainder, with no floored model of that window able to reproduce the count. Time selection is now the only bounds mode a capture can reach; the console verdict line and the `RENDER_STARTPOS`/`ENDPOS` read-back probe that answered this are gone. Full observation: `src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection`
- [ ] **Still open — Auto and Manual tail.** `checkRenderedBounds` judges `TailMode::None` only (Auto/Manual add frames by design), so the settled result covers those two by INFERENCE, not observation, and the inference rests on an unverified PREMISE too: that the (retired) floor applied to the bounds identically across all three tail modes, and that all three now hand the window over the same way. Neither is measured — both live short renders that settled the bounds mode were `TailMode::None`; no Auto or Manual capture has been observed at all. **On Auto/Manual, the ONLY automatic check left is the 0-byte gate (`checkRenderedFileNotEmpty`)** — there is no automatic bounds signal for those two modes at all until this bullet is closed by hand. What would establish it: repeat an off-grid-start capture at **Manual** over a source that is loud right to the window's end, and check the landed file's frames against window + `tailMs` — a floored edge shows up in that count. **Auto** cannot be checked by count (it trims trailing silence), so it needs the null test by ear/inversion against the source instead
- [ ] `[verify — DAW]` A tail is assumed to render PAST the window end — the SDK header (`:3048`) confirms only that `RENDER_TAILMS` is a length in ms, not that it extends past the end. If that assumption is wrong, a tail capture is silently SHORTER than its window with no detector at all. Report whether either tail capture comes up short against the source
- [ ] A refused render is kept for diagnosis at `<project folder>/reasampler_refused/` (the refusal line names the path; a failed move leaves it unindexed in the bank folder and says so). Delete the folder when done — nothing in the bank references it
- [ ] **If a capture is refused for a short render**, report the refusal line verbatim. A message naming `floored to the millisecond` means the floor is back on a mode measured escaping it; a shortfall of one or two frames with no such sentence may be the gate's own edge-convention tolerance rather than the render (`render_window.h`'s `renderHonoredBounds`)
## Names and channels
- [ ] Capture from a named track — the card reads `<Track> MM-DD HHMM`; capture again the same minute and the second carries an ordinal (`PLAN.md:2199`)
- [ ] Capture from an unnamed track, and from a multi-item selection — both readable, `+N` present on the multi (`PLAN.md:2199`, `docs/COMPLETED.md:862`)
- [ ] Load a named capture into ReaSampler 9000 — the same name shows there (`PLAN.md:2200`)
- [ ] The card label stays legible over its scrim at every card size (`docs/COMPLETED.md:863`)
- [ ] Capture a dead-center mono source — the `.wav` is roughly half the size of the equivalent stereo capture (`PLAN.md:2216`)
- [ ] Insert that collapsed file on a stereo track and null it against the source — confirms REAPER sums a 1-channel item at unity (`PLAN.md:2218`)
- [ ] Capture a true-stereo source — stays 2-channel, and both it and the collapsed file load into the instrument correctly (`PLAN.md:2217`)
- [ ] Capture from a named track — the card reads `<Track> MM-DD HHMM`; capture again the same minute and the second carries an ordinal (`docs/COMPLETED.md` §"Ψ-W2-T1")
- [ ] Capture from an unnamed track, and from a multi-item selection — both readable, `+N` present on the multi (`docs/COMPLETED.md` §"Ψ-W2-T1")
- [ ] Load a named capture into ReaSampler 9000 — the same name shows there (`docs/COMPLETED.md` §"Ψ-W2-T1")
- [ ] The card label stays legible over its scrim at every card size (`docs/COMPLETED.md` §"Ψ-W2-T1")
- [ ] Capture a dead-center mono source — the `.wav` is roughly half the size of the equivalent stereo capture (`docs/COMPLETED.md` §"Ψ-W2-T2")
- [ ] Insert that collapsed file on a stereo track and null it against the source — confirms REAPER sums a 1-channel item at unity (`docs/COMPLETED.md` §"Ψ-W2-T2")
- [ ] Capture a true-stereo source — stays 2-channel, and both it and the collapsed file load into the instrument correctly (`docs/COMPLETED.md` §"Ψ-W2-T2")
## Mode switching
- [ ] Solo tracks in Arrange, switch to Design, solo different tracks, switch back — each mode restores its own solo set verbatim (`PLAN.md:2151`)
- [ ] Attempt a mode switch while the transport is playing, then while recording — both refuse, visibly (`PLAN.md:2151`)
- [ ] Click the footer mode segment, save, reopen the project — the mode persisted (`PLAN.md:2152`, `docs/COMPLETED.md:841`)
- [ ] Solo tracks in Arrange, switch to Design, solo different tracks, switch back — each mode restores its own solo set verbatim (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] Attempt a mode switch while the transport is playing, then while recording — both refuse, visibly (`docs/COMPLETED.md` §"Ψ-W1-T2")
- [ ] Click the footer mode segment, save, reopen the project — the mode persisted (`docs/COMPLETED.md` §"Ψ-W1-T2")
## Actions and drops
- [ ] Add the import action to a Media Explorer toolbar and fire it from there — it imports (`PLAN.md:2162`)
- [ ] Fire the existing Main-section import binding — still works (`PLAN.md:2163`)
- [ ] Unload/reload (restart REAPER) — no duplicate Media Explorer entry in the action list (`PLAN.md:2164`)
- [ ] Drag one card across the arrange, over an FX window, over the TCP/MCP and back — cue changes per surface, every transition reverses (`PLAN.md:2176`, `PLAN.md:2177`)
- [ ] Drag fast, and drag onto a narrow TCP — target class still resolves; no release anywhere in REAPER is a silent no-op (`PLAN.md:2178`, `docs/COMPLETED.md:858`)
- [ ] Drop a single card into the arrange — an item lands at the pointer's track and time (`PLAN.md:2174`)
- [ ] Drag-out to an external app twenty-plus times in a row — audio arrives every time; this is a soak, a single pass is not a gate (`docs/COMPLETED.md:109`)
- [ ] Drop a capture onto an FX container — the instrument loads with that capture (`docs/COMPLETED.md:110`)
- [ ] Add the import action to a Media Explorer toolbar and fire it from there — it imports (`docs/COMPLETED.md` §"Ψ-W1-T3")
- [ ] Fire the existing Main-section import binding — still works (`docs/COMPLETED.md` §"Ψ-W1-T3")
- [ ] Unload/reload (restart REAPER) — no duplicate Media Explorer entry in the action list (`docs/COMPLETED.md` §"Ψ-W1-T3")
- [ ] Drag one card across the arrange, over an FX window, over the TCP/MCP and back — cue changes per surface, every transition reverses (`docs/COMPLETED.md` §"Ψ-W1-T4")
- [ ] Drag fast, and drag onto a narrow TCP — target class still resolves; no release anywhere in REAPER is a silent no-op (`docs/COMPLETED.md` §"Ψ-W1-T4")
- [ ] Drop a single card into the arrange — an item lands at the pointer's track and time (`docs/COMPLETED.md` §"Ψ-W1-T4")
- [ ] Drag-out to an external app twenty-plus times in a row — audio arrives every time; this is a soak, a single pass is not a gate (`docs/COMPLETED.md` §"Θ-W1-T2" — "Neither acceptance criterion has actually been met yet")
- [ ] Drop a capture onto an FX container — the instrument loads with that capture (`docs/COMPLETED.md` §"Θ-W1-T2" — "Neither acceptance criterion has actually been met yet")
## Bank packages
- [ ] **Run in full.** `docs/verify-package-transfer.md` — the whole cross-machine
export/import round trip: writes-one-file, the transfer itself, re-importing the
same file never overwrites, the round trip back to the source, the too-new /
truncated / mid-payload-corruption refusals (each an exact string), the
unsaved-project refusals, and drag-and-drop (`docs/COMPLETED.md` §"Ε-W3-T1")
- [ ] Force a degraded tracking ledger and confirm the import refuses **before the
file picker opens**: save a project with a bank, close REAPER, edit the saved
`.rpp`'s `owned_files` ext-state value inside its `<REASAMPLER ...>` block — corrupt
the JSON for the `Unreadable` case, or bump `"v":2` to `"v":3` for the
`FutureVersion` case — reopen the project, then run *ReaSampler: import bank
package (.rsbank)*. Read off: the console prints the ledger-refusal block and no
file dialog ever appears (`origin_ledger.h`'s `LedgerStatus` and `ledgerDegraded`,
`package_import_action.cpp`'s `ledgerPermits`)
- [ ] Export dialog: type a destination name with no extension, then again over a
name that already carries a different one (e.g. `mybank.bak`) — read off whether
`GetUserFileName` appended `.rsbank` itself or ReaSampler's own re-append produced
the double-extension result (`mybank.bak.rsbank`) the code expects
(`src/shell/package/CLAUDE.md` §"Gotchas" — "The re-append is suffix-blind")
- [ ] Both the export and the import file dialogs open in front of REAPER's main
window, not behind it — `GetUserFileName` takes no owner window
(`src/shell/package/CLAUDE.md` §"Gotchas" — "`GetUserFileName` also takes no owner window")
- [ ] With a ReaSampler 9000 instance's editor open on the destination project
(Browse view visible), import a `.rsbank` from the docked panel — the browser
reflects the new bank without closing or reopening the editor (the bank-generation
bump, `session.h`'s `bumpBankGeneration`, polled by the instrument at
`processor_reload.cpp`'s `pollBankSync`)
- [ ] Drag two or more `.rsbank` files onto the docked panel in one drop — each lands
as its OWN new bank, never merged into one, and if the tracking ledger is degraded
the refusal prints ONCE for the whole drop rather than once per file
(`panel_window.cpp`'s `handleDropFiles`)
- [ ] Kill REAPER (or the process) partway through an import so a partial bank file
is stranded under its real name in the bank folder, then re-run the same import
into the same project — read off what happens. Whether the import verb should
pre-clean that stale debris is an open question, not yet decided
(`src/shell/package/CLAUDE.md` §"Gotchas" — "A crash mid-export strands the `.rsbanktmp` sibling")
## The resample bake
- [ ] Bake a dialed sound — the banked file sounds like what the editor was playing (`docs/COMPLETED.md:737`)
- [ ] Bake the result twice more — iteration composes, nothing is lost per pass (`docs/COMPLETED.md:737`)
- [ ] Save and reopen after a bake — the instance still points at the baked capture (`docs/COMPLETED.md:737`)
- [ ] Confirm no bake put an item in the arrange, and the superseded file is still on disk (`docs/COMPLETED.md:737`)
- [ ] Bake from an instance in a background project tab — refuses rather than writing into the wrong bank (`docs/COMPLETED.md:725`)
- [ ] Load the VST with the extension not installed — the resample affordance reads unavailable, not silently lossy (`docs/COMPLETED.md:731`)
- [ ] After a bake: instance is in Trigger with start point reset, channel mode and preview velocity survived (`docs/COMPLETED.md:714`, `docs/COMPLETED.md:720`)
- [ ] Gate mode + active sustain loop — "Bake Hold" appears within ~500 ms, its label fits its cell, its travel is duration-ordered (`docs/COMPLETED.md:806`)
- [ ] Bake a dialed sound — the banked file sounds like what the editor was playing (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
- [ ] Bake the result twice more — iteration composes, nothing is lost per pass (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
- [ ] Save and reopen after a bake — the instance still points at the baked capture (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
- [ ] Confirm no bake put an item in the arrange, and the superseded file is still on disk (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Not demonstrated")
- [ ] Bake from an instance in a background project tab — refuses rather than writing into the wrong bank (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Two behaviors worth recording")
- [ ] Load the VST with the extension not installed — the resample affordance reads unavailable, not silently lossy (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Two behaviors worth recording")
- [ ] After a bake: instance is in Trigger with start point reset, channel mode and preview velocity survived (`docs/COMPLETED.md` §"Ξ-W2-T1" — "Reset-scope classifications made at review")
- [ ] Gate mode + active sustain loop — "Bake Hold" appears within ~500 ms, its label fits its cell, its travel is duration-ordered (`docs/COMPLETED.md` §"Ξ-W3-T1")
## Render in place
- [ ] Fire *Render track in place* over a track with a range selected — solo the source
and the new sibling track, invert one track's polarity, and confirm silence. This is
Ρ's own trust anchor: the placement is the null test performed automatically
(`docs/product/render-in-place.md` §"DAW-verification obligations",
`docs/COMPLETED.md` §"Phase Ρ — Render in place")
- [ ] Render in place from three source positions in turn — a normal mid-folder track, a
track that is last in its folder, and a folder-parent track — each time confirm the new
sibling track lands at the same nesting level as the source and that the folder bus
feeds (or bypasses) it correctly. Then, to settle whether `TrackList_AdjustWindows(false)`
is actually needed: comment out that call in `render_in_place.cpp`'s
`RunRenderTrackInPlace` (it was added preemptively, answering a question the spec had
left open rather than one the code confirmed), rebuild, and repeat the folder-parent
case — if nesting still displays correctly with the call removed, it can be dropped in a
follow-up (`docs/product/render-in-place.md` §"DAW-verification obligations")
- [ ] Render in place from a dead-centre (channel-identical) source — confirm the placed
item is mono, and confirm it plays back at the same perceived level the stereo source
did before the render. This is root `CLAUDE.md`'s existing mono-summing
`[verify — DAW]`, promoted to load-bearing because Ρ is the first path that places a
collapsed render into the mix automatically (`docs/product/render-in-place.md`
§"DAW-verification obligations")
- [ ] Fire Render track in place once from Arrange and once from Design. From Arrange:
confirm the source track parks and the result track is visible and in the mix. From
Design: confirm the source stays on the bench, the result track is parked too, then
switch to Arrange and confirm the result track appears in the source's place. **In
both cases wait out at least one panel timer tick before checking membership** — that
is the check that catches a missing explicit-tag-wins filter or an untagged item,
either of which silently reverses the ruling that the result track is always an
Arrange member (`docs/product/render-in-place.md` §"Mode transitions — the source
parks, the result goes to Arrange")
- [ ] Render in place, then press Ctrl-Z once — confirm the new track and its item are
both gone, the source track's folder depth is restored, the rendered file itself is
still on disk, and the source track is still tagged Design (`docs/product/render-in-place.md`
§"DAW-verification obligations")
- [ ] Render in place from a named source, then run it again over the resulting (already
`Capture `-prefixed) track — confirm the name does not stack a second prefix, and
confirm the new sibling's colour matches the source's (the colour clone has no unit
coverage at all). Repeat once from an unnamed source and confirm the result reads
`Capture Track N` (`docs/product/render-in-place.md` §"DAW-verification obligations";
the name-composition logic itself — apart from the live colour clone and the real
`GetTrackName`/`P_NAME` round trip — is unit-tested in `tests/test_capture_name.cpp`)
- [ ] Save a project into a folder whose recording path is set away from the default
(Project Settings → Media → Path), then Render track in place — confirm the rendered
file lands in that configured recording path, not the project folder itself
(`render_in_place.cpp`'s `RunRenderTrackInPlace`, `GetProjectPathEx`)
- [ ] Render in place, save the project, and reopen it — confirm the result track (which
carries an explicit `kArrangeModeId` membership record, unlike the shipped
tag-selected-tracks action which never writes one) behaves identically, in every
mode-switch and visibility check, to an ordinary untagged Arrange track. The JSON
round-trip itself is unit-tested (`tests/test_view_mode_model.cpp`); this is the
live-view half that isn't (`docs/product/render-in-place.md` §"Mode transitions — the
source parks, the result goes to Arrange")
## Instrument migration
- [ ] Open a project saved before the zone retirement — the instance reopens on its first zone and sounds the same (`docs/COMPLETED.md:85`)
- [ ] Such an instance with implicit channel mode + a stereo capture reopens **Stereo** — confirm that is acceptable by ear (`docs/COMPLETED.md:78`)
- [ ] Open a project saved before the zone retirement — the instance reopens on its first zone and sounds the same (`docs/COMPLETED.md` §"Θ-W1-T1")
- [ ] Such an instance with implicit channel mode + a stereo capture reopens **Stereo** — confirm that is acceptable by ear (`docs/COMPLETED.md` §"Θ-W1-T1")
## Look and feel
- [ ] Sign off by eye in a live editor window: knob arcs, needles, envelope splines, waveform outline (`docs/COMPLETED.md:669`, `PLAN.md:375`)
- [ ] Same pass for legibility: text sizes, arc weight, and whether the waveform stroke thickens the docked panel (`docs/COMPLETED.md:613`, `PLAN.md:348`)
- [ ] Piano strip at the 840 px default — keys tile uniformly, the 37 px end gutters read as acceptable (`docs/COMPLETED.md:219`)
- [ ] Resize the editor across several widths — gutters stay symmetric, no key width jumps (`docs/COMPLETED.md:217`)
- [ ] Set host/OS scaling to 150% then 200% — record how the strip and the AA strokes actually look (`docs/TODO.md:418`, `docs/COMPLETED.md:226`)
- [ ] Sign off by eye in a live editor window: knob arcs, needles, envelope splines, waveform outline (`docs/COMPLETED.md` §"Θ-W7-T1", `docs/COMPLETED.md` §"Θ-W6-T1" — "Antialiasing pass")
- [ ] Same pass for legibility: text sizes, arc weight, and whether the waveform stroke thickens the docked panel (`docs/COMPLETED.md` §"Θ-W6-T1", `docs/COMPLETED.md` §"Θ-W6-T1" — "Sizing")
- [ ] Piano strip at the 840 px default — keys tile uniformly, the 37 px end gutters read as acceptable (`docs/COMPLETED.md` §"Θ-W2-T3")
- [ ] Resize the editor across several widths — gutters stay symmetric, no key width jumps (`docs/COMPLETED.md` §"Θ-W2-T3")
- [ ] Set host/OS scaling to 150% then 200% — record how the strip and the AA strokes actually look (`docs/TODO.md` §"High-DPI host scaling is unverified (distinct from the antialiasing audit)", `docs/COMPLETED.md` §"Θ-W2-T3" — "Width uniformity is guaranteed in client pixels only")
+82 -57
View File
@@ -22,9 +22,9 @@ stated; contradict it in review with an argument, not a preference.
**A bank package is one file that carries one bank — its audio and its index —
out of a project and into another.** Today a bank is per-project by construction:
the audio sits in `<projectDir>/reasampler_bank/` (`core/capture/capture_paths.h:16`,
the audio sits in `<projectDir>/reasampler_bank/` (`core/capture/capture_paths.h`'s
`kBankSubfolder`) and the index that gives that audio meaning lives in the `.rpp`'s
project ext state under the `"reasampler"` namespace (`src/ext_keys.h:25`,
project ext state under the `"reasampler"` namespace (`src/ext_keys.h`'s
`kProjExtBanksKey`). The two travel together with the project and nowhere else.
Export writes both halves into a single `.rsbank` file; import lands them into
another project's bank folder and index.
@@ -116,7 +116,7 @@ cases, and still no compression.
**The two costs, accepted with the ruling.** (1) The package is **opaque without our
tool** — no unzip-and-look support path. (2) We own the hostile-input hardening of our
own parser, to the discipline `bank_model::deserialize` and `parseLedger` already
carry — *error signaled, never UB* (`bank_model.h:204-206`). Both are priced in; a
carry — *error signaled, never UB* (`bank_model.h`'s `BankModel::deserialize`). Both are priced in; a
later "let's make it inspectable" impulse is a new phase's argument, not this one's.
**This was a one-way door and it is now shut** — packages are in users' hands the day
@@ -133,28 +133,29 @@ moves from here.
The repo already carries **two** versioning mechanisms, and they answer different
questions:
1. **A blob-schema ladder.** `src/core/tracking/origin_ledger.cpp:8-30` states the
ladder for the `owned_files` blob in a header comment (v1 legacy path-only, v2
1. **A blob-schema ladder.** `src/core/tracking/origin_ledger.cpp`'s version-ladder
header comment states the
ladder for the `owned_files` blob (v1 legacy path-only, v2
current), pins `constexpr int kLedgerVersion = 2`, and — the load-bearing part —
**reads and validates `"v"`, not merely writes it**: "A version above
`kLedgerVersion` is therefore its own degraded status, never a Loaded ledger"
(`:20-21`). The parse outcome is a three-way `Ok` / `Malformed` / `FutureVersion`
(`:171`, `:185`), deliberately distinguished so the operator gets the right
(the same comment). The parse outcome is a three-way `Ok` / `Malformed` / `FutureVersion`
(`origin_ledger.cpp`'s `ParseOutcome` enum and `parseStored`), deliberately distinguished so the operator gets the right
recovery advice. A *field-vocabulary* gap behaves oppositely: an unrecognized
`OriginKind` integer degrades to `Unknown` rather than failing the parse
(`:32-40`), because "a vocabulary gap must not halt the prune"
(`src/core/tracking/CLAUDE.md:82-86`).
2. **An app writing-version stamp.** `src/core/version/app_version.h:165-186`
(`origin_ledger.cpp`'s `kindFromInt`), because "a vocabulary gap must not halt the prune"
(`src/core/tracking/CLAUDE.md` §"Gotchas").
2. **An app writing-version stamp.** `src/core/version/app_version.h`'s
`WritingVersion` with `PreVersioning` / `Unknown` / `Stamped`, classified by
`classifyWritingVersion`, stamped into project ext state by
`src/shell/persist/ext_state_io.cpp:172-176` using `stampVersion()` (the numeric
`ReaSamplerSession::saveToActiveProject` (`src/shell/persist/ext_state_io.cpp`) using `stampVersion()` (the numeric
triple only, no channel suffix). It is informational: an absent stamp is "not an
error and not a warning" (`app_version.h:166-168`).
error and not a warning" (`app_version.h`'s `WritingVersion` comment, the `PreVersioning` case).
**An observation worth recording, not a defect to fix here:** `BankBook` writes
`"version": 1` into the banks blob (`src/core/model/bank_book_json.cpp:37`) but its
`"version": 1` into the banks blob (`src/core/model/bank_book_json.cpp`'s `BankBook::serialize`) but its
parser skips the key along with every other unknown one
(`bank_book_json.cpp:182``if (!r.skipValue()) return false; // version, or unknown`).
(`bank_book_json.cpp`'s `parseBook``if (!r.skipValue()) return false; // version, or unknown`).
The book's version field is therefore **decorative today** — written, never read,
never gating. The ledger's is the precedent to extend; the book's is the precedent
not to repeat.
@@ -175,10 +176,10 @@ advise (an integer tells a user nothing about which build to install).
A single ladder has one bad property: **every change strands every older reader,
even a purely additive one.** That is not hypothetical here — look at what `Sample`
has actually accumulated: `rootNote` and `loop` (`bank_model.h:112-122`,
has actually accumulated: `rootNote` and `loop` (`bank_model.h`'s `Sample::rootNote` / `Sample::loop`,
"additive like `provenance`. Both default cleanly empty"), `captureTimeSigNum` /
`captureTimeSigDenom` (`:103-108`, "0/0 means UNSTAMPED"), `channelCount`
(`:91-96`, "0 = unknown — a pre-field entry"). Every one of those was additive with
`captureTimeSigDenom` (`Sample::captureTimeSigNum` / `Sample::captureTimeSigDenom`, "0/0 means UNSTAMPED"), `channelCount`
(`Sample::channelCount`, "0 = unknown — a pre-field entry"). Every one of those was additive with
a defined absent-value. Under a single ladder, each would have blocked older readers
for no reason.
@@ -197,17 +198,17 @@ message text and the log.
**One change class that looks additive and is not: a new enum value.**
`BankModel::deserialize` *rejects* an out-of-range `SourceMode` or `Tier` rather than
degrading it (`bank_model.cpp:232-239`, `:339-346`), and every enum a package carries
degrading it (`bank_model.cpp`'s `parseSample` — the `sourceMode` and `tier` branches), and every enum a package carries
rides inside the nested `BankModel` blob. So growing either vocabulary is
**structural** and bumps `minReaderVersion` too. This is wider than packages and
predates them: `BankModel::deserialize` is also the live project ext-state parser
(`bank_book_json.cpp:99`), so appending a `SourceMode` value already strands an older
(`bank_book_json.cpp`'s `parseBank`), so appending a `SourceMode` value already strands an older
build opening a newer project's `.rpp`. Phase Ε inherits that property; it did not
cause it, and changing it — degrade-to-`Unknown` at those two sites, the way
`BakeStatus` already does — is a change to the model layer, not a package concern. It
leaves the argument above untouched: the four fields that motivated the two-integer
design are *fields*, and `parseSample`'s `skipValue()` fallback
(`bank_model.cpp:370-372`), plus the manifest parsers' equivalent at each level, still
(`bank_model.cpp`), plus the manifest parsers' equivalent at each level, still
carries them forward.
This is a borrowed pattern, not an invention: Matroska's `EBMLVersion` /
@@ -216,7 +217,7 @@ This is a borrowed pattern, not an invention: Matroska's `EBMLVersion` /
must understand to read me." It costs one extra integer and one writer discipline —
*decide honestly whether your change is additive* — and that discipline is exactly
the one `origin_ledger` already enforces on `OriginKind`
(`src/core/tracking/CLAUDE.md:82-83`: "PERSISTED INTEGERS — never renumber, only
(`src/core/tracking/CLAUDE.md` §"Gotchas": "PERSISTED INTEGERS — never renumber, only
append").
### Both directions, concretely
@@ -225,12 +226,12 @@ append").
Every reader reads every `minReaderVersion <= kPackageFormatVersion`. Absent manifest
keys take their defined defaults, exactly as `Sample`'s additive fields already do,
and exactly as `origin_ledger` lifts a v1 path-only blob into v2 records with kind
`Unknown` and empty ids (`origin_ledger.cpp:14-16`). Unrecognized manifest keys are
`Unknown` and empty ids (`origin_ledger.cpp`'s version-ladder header comment). Unrecognized manifest keys are
skipped, which is already how every parser in this repo behaves
(`bank_book_json.cpp:182`). Unrecognized enum integers (the manifest's own —
(`bank_book_json.cpp`'s `parseBook`). Unrecognized enum integers (the manifest's own —
`BankModel`'s nested ones reject) degrade to their defined `Unknown`-equivalent,
never to the numeric default and never to a parse failure —
`bake_wire`'s rule verbatim (`src/core/wire/CLAUDE.md:83`: "an unrecognized value
`bake_wire`'s rule verbatim (`src/core/wire/CLAUDE.md` §"Modules", the `bake_wire` bullet: "an unrecognized value
decodes as `Failed` rather than as the numeric default `Ok`").
**The user sees:** a normal import summary. Optionally a single console line naming
the older writer version. No dialog, no warning, no ceremony — a supported case is
@@ -240,7 +241,7 @@ not an incident.
written.** `minReaderVersion > kPackageFormatVersion` is a hard stop, before a single
byte is written to the bank folder and before the index is touched. This is exactly
`LedgerStatus::FutureVersion`'s treatment, and for the same reason stated at
`origin_ledger.cpp:18-21`: parsing an unknown shape by old rules "would yield a
`origin_ledger.cpp`'s version-ladder header comment: parsing an unknown shape by old rules "would yield a
plausible-but-partial" result, and a partial bank is worse than no bank.
**The user sees** a message box (`ShowMessageBox`, verified —
`vendor/reaper-sdk/sdk/reaper_plugin_functions.h:6546`,
@@ -273,13 +274,13 @@ will not find out which twelve of forty samples were dropped until they need one
`bank_book_json` precedent applied outward: the book writer "emits the bank
envelope … plus a raw `index` member whose value is the `BankModel` blob verbatim,
so per-bank sample serialization stays owned by `bank_model` and is not duplicated
here" (`bank_book_json.cpp:15-20`). The package does the same, so a future `Sample`
here" (`bank_book_json.cpp`'s file-header comment). The package does the same, so a future `Sample`
field reaches packages for free and the shape has exactly one owner.
- **Per entry, additionally:** the payload's **bare file name** inside the package,
its byte length, and a whole-file `hashBytes` digest
(`core/capture/wav_codec.h:143` — FNV-1a 64-bit over raw bytes, 16-char lowercase
(`core/capture/wav_codec.h`'s `hashBytes` — FNV-1a 64-bit over raw bytes, 16-char lowercase
hex). Note carefully: `hashBytes`, **not** `hashWavContent`. The latter deliberately
skips non-`fmt `/`data` chunks (`wav_codec.h:145-151`), which is right for dedup
skips non-`fmt `/`data` chunks (`wav_codec.h`'s `hashWavContent`), which is right for dedup
identity and wrong for "did these bytes survive the trip." Both hashes are already
in the codebase; the package needs the raw one for integrity and carries the
`Sample`'s existing `contentHash` for dedup, and they are different fields
@@ -306,14 +307,14 @@ guarantee for this feature; overselling it would be the error.
one genuinely security-shaped surface this feature has.
- **The origin ledger.** The ledger is *this project's* record of files *it*
created, and it is the authority prune's protected set is computed from
(`src/core/tracking/CLAUDE.md:5-13`). Importing foreign ownership records would
(`src/core/tracking/CLAUDE.md` §"Scope"). Importing foreign ownership records would
assert this project's authority over another project's history. Instead the
importer writes **its own** birth records for the files it lands, at the moment it
lands them, through the one writer (`ReaSamplerSession::recordCreated`,
`src/shell/persist/session.h:95` — it already takes an `OriginKind`). Without that,
`src/shell/persist/session.h` — it already takes an `OriginKind`). Without that,
every imported file would be "foreign, therefore never reclaimed"
(`core/tracking/CLAUDE.md:24-31`) and a user's bank folder would grow forever.
- **Live-instance usage records** (`rsusage_*`, `src/ext_keys.h:65`). Per-instance
(`core/tracking/CLAUDE.md` §"Invariants", the "No silent gaps" bullet) and a user's bank folder would grow forever.
- **Live-instance usage records** (`rsusage_*`, `src/ext_keys.h`'s `kProjExtUsageKeyPrefix`). Per-instance
runtime state of a specific project's specific FX instances. Meaningless elsewhere.
- **Project state that is not bank state:** which bank was active, the Design View
mode model (`view_state`), the tail setting, the project GUID, the bank-generation
@@ -342,11 +343,11 @@ Four distinct collisions hide under the word "collision," and they need four
different answers.
1. **Sample id.** Ids are minted as `"cap-" + uniqueTag + "-" + fileName`
(`src/shell/capture/capture.cpp:567`) and `"imp-" + …`
(`src/shell/actions/ingest.cpp:269`) — unique within a project, **not** globally.
(`src/shell/capture/capture.cpp`'s `OfflineRenderBackend::capture`) and `"imp-" + …`
(`src/shell/actions/ingest.cpp`'s `importFileIntoActiveBank`) — unique within a project, **not** globally.
Re-importing a package into the project it came from would collide.
**Answer: remint every sample id on import**, under its own prefix, and remap
`Provenance::parentSampleId` (`bank_model.h:45-50`) through the same map — to the
`Provenance::parentSampleId` (`bank_model.h`'s `Provenance` struct) through the same map — to the
reminted parent if that parent came in the same package, cleared otherwise. A
foreign id never enters the destination index. This also makes "import the same
package twice" a clean, duplicative, correct operation rather than an undefined
@@ -354,18 +355,18 @@ different answers.
2. **File name in the destination bank folder.** **Never overwrite.** Overwriting
would destroy an existing capture, and only prune touches existing bank bytes.
Mint a fresh unique name through the existing `deriveBankPaths` +
unique-tag machinery (`core/capture/capture_paths.h:42`), automatically, no
unique-tag machinery (`core/capture/capture_paths.h`'s `deriveBankPaths`), automatically, no
prompt, and report the count in the summary.
3. **Content hash.** `BankModel::add` collapses an equal-`contentHash` add onto the
existing entry (`bank_model.h:144-147`, `AddResult::Collapsed`). Desirable — but
existing entry (`bank_model.h`'s `AddResult::Collapsed`). Desirable — but
if the file was already written to disk before the collapse, it becomes an
instant orphan. **Answer: check the destination bank's `findByHash` BEFORE writing
the payload**; on a hit, skip the write entirely and report "N already present."
This is the one place the import must consult the model before touching the
filesystem, and it is a concrete acceptance criterion rather than an optimization.
4. **Bank display name.** `bank_book` enforces unique display names, trimmed and
case-insensitive ASCII (`src/core/model/CLAUDE.md:22-26`; `createBank`'s own
contract at `bank_book.h:92-96` — *"Drums"/"drums"/" Drums " collide, including
case-insensitive ASCII (`src/core/model/CLAUDE.md` §"Invariants", the "Bank identity, movement, dedup" bullet; `createBank`'s own
contract at `bank_book.h` — *"Drums"/"drums"/" Drums " collide, including
against the pool's "Pool"*), so `createBank("Drums")` into a project that already
has "Drums" returns `false` with no mutation. **Answer: an automatic numeric
suffix, specified below.** No prompt, no overwrite, no refusal.
@@ -381,7 +382,7 @@ trim, the seed is the literal `Imported bank`.
**The probe.** Let `seed` be that string and `fold(x)` be `BankBook`'s own uniqueness
key — strip leading/trailing ASCII whitespace, lower-case ASCII letters
(`bank_book.h:252-258`). Take the **first** name in this sequence whose fold is not
(`bank_book.h`'s `BankBook::nameKey`). Take the **first** name in this sequence whose fold is not
already carried by a bank in the destination book:
seed, seed + " 2", seed + " 3", seed + " 4", …
@@ -410,7 +411,7 @@ implementations diverge:**
`B + 1` candidates is free by pigeonhole, so no cap is needed and none should be
added.
4. **The fold has exactly one home.** `import_plan` must **not** re-implement
`nameKey``bank_book.h:252-258` says in as many words that a drifted second copy
`nameKey``bank_book.h`'s `BankBook::nameKey` says in as many words that a drifted second copy
would let the uniqueness invariant be violated. The probe therefore runs behind
`BankBook`'s own folding, which means Ε-W2-T2 adds **one additive public `const`
member** to `BankBook` (recommended: `std::string uniqueDisplayName(const
@@ -422,7 +423,7 @@ name. Sample ids are reminted by collision rule 1 regardless of whether a name
collision occurred, and the two mechanisms are independent. **`Sample` display names
are never suffixed** — two banks may legitimately hold a sample called `"Kick"`, and
`resample_name`'s own contract already states that sample display names are not unique
(`resample_name.h:13-16`). Bank-folder file names are handled by collision rule 2 and
(`resample_name.h`'s `nextIterationName`). Bank-folder file names are handled by collision rule 2 and
are unaffected by the bank's name. `slot_map` positions ride along unchanged.
**The pool case is guaranteed, not hypothetical.** Exporting the pool is in scope (the
@@ -456,7 +457,7 @@ settled on: report before acting, and never leave a half-state that looks whole.
| Destination package file exists | export | Platform save dialog's own overwrite confirm | Native dialog |
| Write fails partway | export | Temp file in the destination directory, atomic rename only on complete success | Console error; no `.rsbank` left behind. A truncated package must never exist |
| `minReaderVersion` above this build | import | Refuse whole. Nothing written, index untouched | The three-part message box above (package needs / this build reads / what to install) |
| Malformed or truncated container | import | Refuse whole. Reported **distinctly from** the version case | "This file is not a readable bank package (corrupt or truncated)." The distinction matters: the two have opposite recoveries — one is "install a newer build," the other is "get an intact copy." `origin_ledger.cpp:178-185` makes exactly this distinction for exactly this reason |
| Malformed or truncated container | import | Refuse whole. Reported **distinctly from** the version case | "This file is not a readable bank package (corrupt or truncated)." The distinction matters: the two have opposite recoveries — one is "install a newer build," the other is "get an intact copy." `origin_ledger.cpp`'s `parseStored` makes exactly this distinction for exactly this reason |
| Entry name contains a path separator, `..`, or is absolute | import | Refuse whole, before any write | "This package is not well-formed." Hostile input, not user error — no need to elaborate |
| Payload hash mismatch on any entry | import | Refuse whole, before landing anything | "This bank package is damaged (entry `<name>` failed its integrity check). Nothing was imported." |
| A write fails mid-import (disk full, permission) | import | Roll back: delete the files **this import wrote** and abandon the index mutation | "Import failed and was rolled back. Nothing was added." |
@@ -467,7 +468,7 @@ settled on: report before acting, and never leave a half-state that looks whole.
**On the rollback, and why it is not an invariant breach.** Prune is the single
exclusive file-deletion authority, with exactly one carve-out, stated in one place —
`src/shell/persist/prune_fs.cpp:5-11`: "a shell removing a file it wrote itself
`src/shell/persist/prune_fs.cpp`'s file-header comment: "a shell removing a file it wrote itself
moments earlier and that no index ever referenced is self-cleanup, not authority
over user data … the discriminator is 'did this call create it, and did anything ever
reference it', not where it sits." An import rollback fits that discriminator
@@ -477,11 +478,11 @@ restate it, or a reviewer will correctly read the rollback as a breach.
**On undo.** The index side of an import is one Ctrl-Z, through the same
`persistBankOp` undo batching every bank verb already uses
(`src/shell/bank_ops/CLAUDE.md:29-31`; `Undo_BeginBlock2` / `Undo_EndBlock2` verified
(`src/shell/bank_ops/CLAUDE.md` §"Invariants", the "One bank operation is one Ctrl-Z" bullet; `Undo_BeginBlock2` / `Undo_EndBlock2` verified
at `reaper_plugin_functions.h:7758` and `:7806`). **Undo does not un-write the
files** — they remain on disk, referenced by no index, until a prune reclaims them.
That is the same designed orphaned-until-prune window a non-empty bank delete already
produces (`src/core/model/CLAUDE.md:38-40`). Say it out loud in the spec; do not let
produces (`src/core/model/CLAUDE.md` §"Invariants", the "Bank identity, movement, dedup" bullet). Say it out loud in the spec; do not let
a user infer that Ctrl-Z cleans the folder.
### Import under a degraded tracking ledger (Ε-F3, RULED: refuse)
@@ -494,7 +495,7 @@ reasoning that carried it is recorded below rather than re-argued.
**The trigger, exactly.** The guard fires when `tracking::ledgerDegraded(status)` holds
for the project's loaded ledger status — that is, `LedgerStatus::Unreadable` or
`LedgerStatus::FutureVersion` (`src/core/tracking/origin_ledger.h:94`, `:100-101`).
`LedgerStatus::FutureVersion` (`src/core/tracking/origin_ledger.h`'s `LedgerStatus` and `ledgerDegraded`).
`Fresh` (absent key — a legitimate new project) and `Loaded` both proceed normally.
**Two things the guard is deliberately NOT keyed on:**
@@ -513,7 +514,7 @@ package is read, before any allocation. Making the user find and pick a file we
already decided to refuse is the wrong order.
**What the user sees.** A console block through `ShowConsoleMsg`, mirroring prune's
abort (`src/shell/actions/prune_action.cpp:30-69`) in structure and in tone, because a
abort (`src/shell/actions/prune_action.cpp`'s `doBankPruneFolder` — the `blockedByTracking` console block) in structure and in tone, because a
user who has hit prune's block should recognise this one. Every recovery line names
**this build's** ext-state namespace via `version::extStateNamespace()` — the
beta/stable trap prune already documents, where a beta user handed the stable spelling
@@ -545,7 +546,7 @@ clears the wrong key and is still blocked. Two cases, exactly one of which fires
> them could be given a birth record, and every one would be permanently unreclaimable.
**The recovery path.** The status is written only by `loadFromProject`, so it is sticky
for the session (`src/shell/persist/CLAUDE.md:39-46`): repair or clear the key
for the session (`src/shell/persist/CLAUDE.md` §"Invariants", the "A ledger this build cannot read is degraded" bullet): repair or clear the key
(malformed case only), or install the newer build (future-version case), **reopen the
project**, then import again. The package needs no re-export, and nothing about the
destination project was changed by the refusal.
@@ -557,7 +558,7 @@ likely to want to. Only the landing side refuses.
**Why the ruling went this way.** The rejected option — allow the import behind an
up-front confirm — matched the accepted residual already stated at
`core/tracking/CLAUDE.md:24-31`, where a capture made during a degraded session is
`core/tracking/CLAUDE.md` §"Invariants" (the "No silent gaps" bullet), where a capture made during a degraded session is
recorded in memory but not persisted and degrades to foreign. The argument that carried
is **scale**: that residual contemplates *one* untracked capture, and a bulk import can
strand two hundred files in a single gesture. Same mechanism, different animal. A
@@ -611,7 +612,7 @@ Two new directories, following the split the whole repo turns on.
and `classifyPackageVersion(formatVersion, minReader) -> Readable | TooNew |
Malformed`. The ladder lives with the framing because the ladder *is* the framing's
contract, and it gets a header-comment ladder written the way
`origin_ledger.cpp:8-21` writes one.
`origin_ledger.cpp`'s version-ladder header comment writes one.
- `package_manifest` — the manifest model and its JSON codec, nesting `BankModel`'s
own blob verbatim.
- `bank_package` — header encode / prefix decode / entry layout, composing the two
@@ -643,21 +644,21 @@ responsibility seam, which is what the structural heuristic asks for.
`mode=1` an existing one (import's source). `extension_list` takes the
`'ReaSampler banks|*.rsbank|All files|*.*'` form. `GetUserFileNameForRead` is
explicitly "Superseded, see GetUserFileName" (`:3796`) and is not used. No fallback
is needed: `src/app/main.cpp:15` defines `REAPERAPI_IMPLEMENT` without
is needed: `src/app/main.cpp`'s `#define REAPERAPI_IMPLEMENT` appears without
`REAPERAPI_MINIMAL`, so the resolver walks the full table (`GetUserFileName` at
`:9084`), and `main.cpp:292-293` refuses to load the extension if any one function
`:9084`), and `REAPER_PLUGIN_ENTRYPOINT`'s `REAPERAPI_LoadAPI` check refuses to load the extension if any one function
fails to resolve — so no REAPER build that loads us can lack it.
- `export_bank` / `import_bank` — the promptless verbs, mirroring
`src/shell/bank_ops/`'s pattern exactly: take a `ReaSamplerSession&`, do the work,
return an outcome, **no prompts and no message boxes**. The bindable action and the
panel menu item are then thin skins over one verb apiece, so the logic has one home
(`src/shell/bank_ops/CLAUDE.md:1-12`).
(`src/shell/bank_ops/CLAUDE.md` §"Scope").
**The dependency-shape criterion, stated because the brief demands it.** The pure
planners take **explicit value inputs** — the decoded manifest, the destination
`BankBook`, the set of file names present in the bank folder — never a session handle,
never a service container, never a "pass me the thing that has everything." The shell
*gathers*; the core *decides*. That is the same shape `src/shell/persist/CLAUDE.md:11`
*gathers*; the core *decides*. That is the same shape `src/shell/persist/CLAUDE.md` §"Scope"
already states ("it gathers rather than decides"). If a circular dependency shows up
during the build, the fix is a service split or a thin interface at the seam — never
threading an extra parameter through a chain of constructors, and never handing a
@@ -690,12 +691,12 @@ constructors.
freshly-generated pair.
- **Bank generation.** Import mutates bank content that live ReaSampler 9000
instances may play, so it must `bumpBankGeneration()`
(`src/shell/persist/session.h:108`, whose own comment says call sites "err toward
(`src/shell/persist/session.h`'s `ReaSamplerSession::bumpBankGeneration`, whose own comment says call sites "err toward
bumping"). Export mutates nothing and must bump nothing, write no ext state, and
open no undo point.
- **Beta/stable channel isolation.** Packages are channel-**agnostic** and this is
deliberate. Channel isolation exists so a beta cannot rewrite a stable project's
ext state (`app_version.h:73-76`); a package is a file the user moves by hand, not
ext state (`app_version.h`'s `extStateNamespace` — the ISOLATION comment); a package is a file the user moves by hand, not
ambient project state, so there is no isolation property to preserve. A beta build
and a stable build at the same package format read each other's packages, and that
is the useful behaviour. The version ladder — not the channel — is what gates.
@@ -725,6 +726,30 @@ contemplates one untracked capture, an import strands hundreds).
---
## Implementation decisions — Ε-W2-T1
Not [Daniel]-class forks — both were `[propose at review]` calls in `docs/PLAN.md`'s
Ε-W2-T1 track, answered at implementation review rather than by Daniel, and recorded
here per this phase's own convention for keeping such answers where the design lives
rather than only in the track's own now-stale open-questions line.
- **Affordance: both the bindable action and the panel row.** The action targets the
**active** bank and is the only spelling that can reach the **pool** (the panel's
`showTabMenu` returns early on `isPool()` — a named-bank-tab context menu has no tab
to right-click for the pool), while the exported unit's own definition above includes
the pool. The panel row is the direct gesture on a specific named bank. Neither
subsumes the other.
- **Default file name: the bank's display name**, sanitized through
`capture_paths::sanitizeStem`, seeded into `<projectDir>/<stem>.rsbank`. A
project-derived name was the rejected alternative: three banks exported from one
project must produce three distinguishable files, and a project-derived name
collides on the second export. Known wart, worth recording rather than hiding:
`sanitizeStem` collapses an all-non-ASCII display name to the literal `capture`, so
two such banks still collide — the existing rename verb is the recovery, same as the
import-side auto-suffix collisions above.
---
## Non-goals and guardrails
- **No auto-insertion of imported audio into the arrange.** Same rule as capture.
+26 -16
View File
@@ -53,12 +53,20 @@ snapshot/restore, forces dither and all normalize-postprocessing off, and render
32-bit float. The tail wires into that existing path — no new render trigger, no
new backend.
### Bounds are always custom — so the tail bit is always `&1`
### Bounds are always the time selection — so the tail bit is always `&4`
The backend renders with `RENDER_BOUNDSFLAG = 0` (custom time bounds) for **every**
scope and every range type: it sets `RENDER_STARTPOS` / `RENDER_ENDPOS` explicitly
from the request's exact seconds (`capture.cpp` ~L352354). It does **not** use the
time-selection / selected-items / regions bounds modes.
The backend renders with `RENDER_BOUNDSFLAG = 2` (time selection) for **every**
scope and every range type: it writes the request's exact seconds into the
project's own time selection via `GetSet_LoopTimeRange` (`capture.cpp` ~L470477;
`RENDER_STARTPOS`/`RENDER_ENDPOS` are also written, as a defensive no-op for a
mode-0-only field, but the window itself travels in the time selection). It does
**not** use the custom-time-bounds mode (`RENDER_BOUNDSFLAG = 0`) — that mode was
tried and retired: DAW observation showed REAPER resolving a custom-bounds window
on a whole-millisecond grid AT RENDER TIME, flooring the end and rendering exactly
the floored frame count, which silently broke the exact-bounds precision
invariant. The time-selection mode does not floor the window. (The one narrative
home for that finding is `render_settings.h`'s `kRenderBoundsTimeSelection`; this
doc points there rather than retelling it.)
`RENDER_TAILFLAG` is a bitmask keyed to the **bounds mode**, not the capture range
type (header line 3047):
@@ -69,18 +77,20 @@ RENDER_TAILFLAG : &1=custom time bounds, &2=entire project, &4=time selection,
&32=selected project markers/regions
```
Because we always render in custom-time-bounds mode, **the only tail bit that ever
applies is `&1`**. There is no per-range-type tail-flag decision to make — a razor
capture, a time-selection capture, and an item capture are all custom-bounds
renders under the hood, so all three take `RENDER_TAILFLAG = 1`.
Because we always render in time-selection mode, **the only tail bit that ever
applies is `&4`**. There is no per-range-type tail-flag decision to make — a razor
capture, a time-selection capture, and an item capture are all time-selection-bounds
renders under the hood, so all three take `RENDER_TAILFLAG = 4`.
> **Correction to the framing brief.** The brief asked us to pick a
> `RENDER_TAILFLAG` bit *per capture range type* (time selection vs. razor vs. item)
> and flagged `&32` as "markers/regions." The header (line 3047) says `&32` =
> *selected project regions* and `&8` = *all markers/regions* — but neither matters:
> our renders are all `RENDER_BOUNDSFLAG = 0`, so the tail bit is `&1` unconditionally.
> The existing `kTailFlagCustomBounds = 1.0` constant in `capture.cpp` (~L80) is
> already correct; the field wiring is what's missing.
> our renders are all `RENDER_BOUNDSFLAG = 2`, so the tail bit is `&4` unconditionally.
> The existing `kTailFlagTimeSelection = 4` constant in
> `src/core/capture/render_settings.h` (the bounds mode's own bit, per bounds mode —
> header line 3047) is already correct — it was right from the start; the wording
> above it (which had assumed a custom-bounds render) was what was wrong.
### Mode 1 — Automatic (default): generous tail + auto-trim to -72 dB
@@ -88,7 +98,7 @@ Set, in addition to the exact `STARTPOS`/`ENDPOS` already driven:
| Setting | Value | Meaning / header ref |
|---|---|---|
| `RENDER_TAILFLAG` | `1` | apply tail for custom time bounds (line 3047, `&1`) |
| `RENDER_TAILFLAG` | `4` | apply tail for time selection (line 3047, `&4`) |
| `RENDER_TAILMS` | `8000` | the 8 s cap, in ms (line 3048) |
| `RENDER_NORMALIZE` | `32768` | **only** the trim-ending-silence bit (line 3051, `&32768`) |
| `RENDER_TRIMEND` | `≈ 0.000251` | -72 dB threshold (line 3062; scaling below) |
@@ -156,7 +166,7 @@ The existing (currently unwired) `CaptureRequest.renderTail` / `tailMs` fields
| Setting | Value |
|---|---|
| `RENDER_TAILFLAG` | `1` |
| `RENDER_TAILFLAG` | `4` |
| `RENDER_TAILMS` | `request.tailMs` (clamped to the 8 s cap — see below) |
| `RENDER_NORMALIZE` | `262144` (`kNormalizeDisableAll`, unchanged) |
| `RENDER_TRIMEND` | not set / irrelevant (trim bit is clear) |
@@ -177,9 +187,9 @@ adds a third state, so the wiring is a small enum, not a bool:
- **None** (default for null-test / verify captures, and the current two-scope
action defaults): `RENDER_TAILFLAG = 0`, `RENDER_TAILMS = 0`, normalize =
disable-all. Exact bounds. Byte-identical to today.
- **Auto** (the new user-facing default for tail-on captures): tailFlag `1`,
- **Auto** (the new user-facing default for tail-on captures): tailFlag `4`,
tailMs `8000`, normalize `32768` (surgical trim), trimEnd `0.00025119`.
- **Manual(ms)**: tailFlag `1`, tailMs `clamp(ms, 8000)`, normalize `262144`
- **Manual(ms)**: tailFlag `4`, tailMs `clamp(ms, 8000)`, normalize `262144`
(disable-all), no trim.
Recommended shape: replace `bool renderTail` with a `TailMode { None, Auto,
+15 -15
View File
@@ -226,25 +226,25 @@ them through the reorg, not to change them:
These are the naming equivalent of the JSON-`Parser` DRY violation — concrete hazards, not taste:
1. **Four hand-rolled `Parser` classes, one name.** `class Parser` is defined **four times**
`bank_model.cpp:306`, `bank_book.cpp:663`, `owned_manifest.cpp:107`, `view_mode_model.cpp:654`.
`bank_model.cpp`, `bank_book.cpp`, `owned_manifest.cpp`, `view_mode_model.cpp`.
Q-W1 already deletes three of them by extracting `core/json`; the naming rule is that the
survivor is **`json::Parser`** (or a more specific `json::Reader`/`json::Writer` pair — see
Q-8), never a bare `Parser` in flat scope.
2. **`FooterRect` and `ButtonRect` are shared across pure UI modules — and the codebase already
*knows* it.** `struct FooterRect` and `struct ButtonRect` are defined in `prune_button.h`
(lines 32, 46) and **reused** by `footer_bar.h`, which carries an explicit in-file "NAME NOTE"
(`footer_bar.h:2734`) documenting that `ButtonRect / FooterRect / SegmentRect / ActionBarRect /
and **reused** by `footer_bar.h`, which carries an explicit in-file "NAME NOTE"
(`footer_bar.h`) documenting that `ButtonRect / FooterRect / SegmentRect / ActionBarRect /
KitBox / KitButtonBox` are "already owned in this namespace" and that new types must carry a
`FooterBar*` prefix to avoid collision. That comment is a smell made visible: the flat
`reasampler::` namespace forces every pure-UI author to hand-check for name collisions before
minting a type. This is the single strongest in-codebase argument for the Q-4 sub-namespaces —
under `reasampler::ui` these shared rect types get one clear owner and the hand-checking stops.
3. **`Sample` (`bank_model.h:69`, the bank metadata struct) vs `AudioSample` (the `peaks` float
3. **`Sample` (`bank_model.h`'s `Sample` struct, the bank metadata struct) vs `AudioSample` (the `peaks` float
alias).** Already flagged in §2.4/Q-4; verified — `Sample` is the model record, `AudioSample`
is a raw PCM float. Under `model::Sample` vs `audio::AudioSample` the collision risk is gone,
but the *names* still read oddly side by side (a `Sample` that is metadata, an `AudioSample`
that is one float). Noted; the namespace split is the required fix, a rename is optional (Q-8).
4. **`Selection` (`bank_grid.h:112`) and `CellRect` (`bank_grid.h:23`) are generic names in a
4. **`Selection` (`bank_grid.h`'s `Selection` struct) and `CellRect` (`bank_grid.h`'s `CellRect`) are generic names in a
flat namespace.** `Selection` in particular is the kind of name a newcomer cannot place without
opening the file. `ui::Selection` / `ui::CellRect` resolve it structurally; no rename needed
beyond the namespace.
@@ -255,23 +255,23 @@ Here the names are legal and non-colliding but do not read on one principle —
at" gap:
1. **The model-family suffixes disagree: `_model` vs `_book` vs `Index`.** Verified: the pure model
modules are `bank_model.{h,cpp}` (owning `class BankIndex`, `bank_model.h:132`), `bank_book.{h,cpp}`
(owning `class BankBook`, `bank_book.h:208`), `view_mode_model.{h,cpp}` (owning `class ViewModeModel`,
`view_mode_model.h:376`), `owned_manifest.{h,cpp}` (owning `class OwnedFileManifest`,
`owned_manifest.h:52`). Four modules, four different file↔class naming relationships:
modules are `bank_model.{h,cpp}` (owning `class BankIndex`, `bank_model.h`), `bank_book.{h,cpp}`
(owning `class BankBook`, `bank_book.h`'s `BankBook`), `view_mode_model.{h,cpp}` (owning `class ViewModeModel`,
`view_mode_model.h`'s `ViewModeModel`), `owned_manifest.{h,cpp}` (owning `class OwnedFileManifest`,
`owned_manifest.h`). Four modules, four different file↔class naming relationships:
`bank_model``BankIndex` (file says "model," class says "index"), `bank_book``BankBook`
(file = class), `view_mode_model``ViewModeModel` (file = class), `owned_manifest``OwnedFileManifest`
(file ≈ class, but the class adds "File"). The `bank_model`/`BankIndex` mismatch is the worst:
the file name and its primary class name share no word. This is a genuine legibility wart — the
fix is a *rename decision* (Q-8), not something the directory move alone resolves.
2. **The `bank_book` "wraps `bank_model`" relationship is invisible in the names.** `BankBook`
(`bank_book.h:208`) is a registry of `Bank` (`bank_book.h:147`), each wrapping a `BankIndex`
(`bank_model.h:132`). The names `Book``Bank``Index` do not read as a containment hierarchy;
(`bank_book.h`'s `BankBook`) is a registry of `Bank` (`bank_book.h`'s `Bank` struct), each wrapping a `BankIndex`
(`bank_model.h`). The names `Book``Bank``Index` do not read as a containment hierarchy;
a reader has to learn it. (Not necessarily worth a rename — "book of banks" is evocative — but
it is the kind of call Q-8 should make deliberately, not by accident.)
3. **`realtime_record.h` (pure) vs `capture_realtime.cpp` (shell) — the word order flips.** Verified:
the pure realtime module is `realtime_record.{h}` (owning `RecordModePlan`/`RecordPhase`/
`RecordTickInputs`, `realtime_record.h:57173`) while its shell is `capture_realtime.cpp`. So the
`RecordTickInputs`, `core/capture/capture_realtime.h`) while its shell is `capture_realtime.cpp`. So the
pure core is `realtime_record` but the shell is `capture_realtime` — the two halves of one feature
are named on inverted word order (`realtime_record` vs `capture_realtime`). Compare the *clean*
shell-pair convention elsewhere: `drag_out` (pure) ↔ `drag_out_win` (shell) — same stem, suffix
@@ -279,7 +279,7 @@ at" gap:
naming-drift instance in the tree (Q-9).
4. **`capture.{h,cpp}` is the *offline* backend shell, but the name claims all of capture.**
Verified: `capture.h` declares `ICaptureBackend`, `OfflineRenderBackend`, **and**
`RealtimeRecordBackend` (`capture.h:112,124,201`), while the realtime *implementation* lives in
`RealtimeRecordBackend` (`capture.h`'s `OfflineRenderBackend`), while the realtime *implementation* lives in
`capture_realtime.cpp` and its pure planner in `realtime_record.h`. So `capture` is really
"capture interface + offline backend," a fat header (the §2.3 Interface-Segregation concern) whose
name oversells its scope. Its Q-W3 hoist (`capture_orchestrator`/`scope_resolve`) is the moment
@@ -290,11 +290,11 @@ at" gap:
Swept for names a newcomer couldn't decode; the tree is mostly clean here (a credit to it). Two
minor notes:
- **`guid_diff` / `GuidBaseline` (`guid_diff.h:40`)** — "GUID diff" is decodable in context (it
- **`guid_diff` / `GuidBaseline` (`guid_diff.h`'s `GuidBaseline`)** — "GUID diff" is decodable in context (it
diffs the live track/item GUID set between polls) but `GuidBaseline` reads more clearly as "the
previous-poll snapshot" than the module name suggests. Low priority; leave unless its `core/view`
relocation invites it.
- **`MinMax` (`peaks.h:30`), `KitBox` (`component_geometry.h:28`)** — terse but correct and local;
- **`MinMax` (`peaks.h`'s `MinMax`), `KitBox` (`component_geometry.h`'s `KitBox`)** — terse but correct and local;
no change. Named here only to record they were swept and cleared.
### 2b.5 What the naming audit does NOT touch (hard boundary)
+31 -29
View File
@@ -25,7 +25,8 @@ with its reasoning stated; contradict it in review with an argument, not a prefe
**Slug convention.** `Λ → l`, so tracks dispatch into `pl-w<wave>-t<track>-<slug>`
Λ-W2-T1 into `pl-w2-t1-linux-compile-blockers`. The two audits already ran under
`pl-w1-t1-build-toolchain-audit` and `pl-w1-t2-source-runtime-audit`. **`docs/PLAN.md:3336`
`pl-w1-t1-build-toolchain-audit` and `pl-w1-t2-source-runtime-audit`. **`docs/PLAN.md`'s
"Worktree slug convention" paragraph
lists the transliterations for Θ / Ξ / Γ / Ψ / Ε / Ρ and does not yet carry Λ — adding it
is a plan-doc edit this phase's PLAN.md entry must make.**
@@ -77,21 +78,21 @@ this doc re-read against the tree while writing.
**The extension is close — two one-line compile blockers stand between the tree and a
GCC/Clang build.**
- `src/shell/panel/draw_kit.cpp:73` passes `DEFAULT_PITCH | FF_DONTCARE` to `CreateFont`.
- `src/shell/panel/draw_kit.cpp`'s `loadFont` passes `DEFAULT_PITCH | FF_DONTCARE` to `CreateFont`.
`FF_DONTCARE` has zero occurrences anywhere in `vendor/WDL/` (L2-01); the file is not
platform-guarded, only its include is (`:7077`). `draw_kit` links into both modules, so
platform-guarded, only its include is (`loadFont`'s whole body). `draw_kit` links into both modules, so
nothing builds.
- `src/shell/actions/instrument_drop_win.cpp:59` calls `GetCurrentProcessId()` inside
`writeTempPreset` with no platform branch anywhere in the TU. SWELL exports
- `src/shell/actions/instrument_drop_win.cpp`'s `writeTempPreset` calls `GetCurrentProcessId()`
with no platform branch anywhere in the TU. SWELL exports
`GetCurrentThreadId` and not this (L2-02). The PID exists only to keep two concurrent
REAPER instances from colliding in the shared temp dir; the atomic counter at `:52`
REAPER instances from colliding in the shared temp dir; the atomic counter in the same function
already carries the intra-process half.
**`core/` is genuinely pure, and it was verified rather than assumed.** Every `#include`
under `src/core/**` is a `core/` sibling, one of 26 standard headers, or the generated
`version_generated.h` — zero REAPER, SWELL, WDL, LICE, VST3 or `windows.h` (T2 §1.1). The
whole directory contains nine preprocessor conditional lines, exactly one of which is a
platform fork, and that one (`capture_paths.cpp:1820`, the Windows case-fold) is *correct*
platform fork, and that one (`capture_paths.cpp`'s `normalizeSlashes`, the Windows case-fold) is *correct*
for Linux with both branches already asserted by `tests/test_capture_paths.cpp`. All 91
test TUs under `tests/` are platform-neutral.
@@ -100,24 +101,25 @@ double-buffered LICE `WM_PAINT`, mouse/wheel/capture, `WM_CAPTURECHANGED` rollba
seven cursors, menus, the keyboard accelerator path, modifier reads, tooltips, drag-out and
`DragQueryFile`/`DragFinish` were each checked by name against `swell-functions.h` /
`swell-types.h` and are present (T2 §1.5). The Windows-only escapes are three:
`DragAcceptFiles` (`panel_window.cpp:148150`, `#ifdef _WIN32`), `SHFileOperationW`
`DragAcceptFiles` (`panel_window.cpp`'s `openPanel`, under `#ifdef _WIN32`), `SHFileOperationW`
(prune), and OLE `DoDragDrop` (drag-out) — each already carrying a non-Windows branch or a
documented reason it does not. **This is the audits' single most load-bearing finding.**
**The dock panel will not appear until the dialog-resource question is answered.**
`panel_window.cpp:135` is `CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), …)`,
`panel_window.cpp`'s `openPanel` calls `CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), …)`,
which SWELL resolves out of a per-module registry populated by a **resgen-generated source
file that is not in the Linux target**: `src/app/CMakeLists.txt:97` has the `target_sources`
line commented out (and `:86` for macOS). The registry head stays null, `SWELL_CreateDialog`
returns null, `panel_window.cpp:137` returns, and the toggle action is a silent no-op with
file that is not in the Linux target**: `src/app/CMakeLists.txt`'s `else()` (Linux) branch has the `target_sources`
line commented out (and the `elseif(APPLE)` branch's own copy for macOS). The registry head stays null, `SWELL_CreateDialog`
returns null, `openPanel`'s `if (!g_panel.hwnd) return;` guard returns, and the toggle action is a silent no-op with
no console line and no Actions-list checkmark (Λ-01, L2-06). Three defects stack in the
commented-out instructions themselves: the script named at `:96` (`mac_resgen.php`) does
commented-out instructions themselves: the script named in the `elseif(APPLE)`/`else()` branches' comment lines (`mac_resgen.php`) does
not exist, the output filename is wrong, and the output is an `#include`-only artifact that
cannot be a `target_sources` entry at all (Λ-01). Λ-F2 decides the route.
**The instrument's Linux editor is a from-scratch X11 job, and today the target does not
configure at all.** `src/shell/instrument/CMakeLists.txt:9` is
`if(WIN32 AND EXISTS "${VST3_SDK}/…/pluginfactory.cpp")` — a conjunction, so a Linux
configure at all.** `src/shell/instrument/CMakeLists.txt`'s
`if(WIN32 AND EXISTS "${VST3_SDK}/…/pluginfactory.cpp")` gate is
a conjunction, so a Linux
configure silently omits `reasampler_vst` even with the submodule slice fully initialised.
Beyond the gate: the wrong module entry point is compiled, the artifact is a file where
Linux wants a directory bundle, nothing hands a VST3 plugin the SWELL function table, and
@@ -127,7 +129,7 @@ drawing survives a window-system change intact. It is the window and event plumb
is entirely absent.
**Nothing about the build is optimized, and the documented ship command is a no-op on
Linux.** Root `CMakeLists.txt:2830` is the complete list of language settings — there is
Linux.** Root `CMakeLists.txt`'s `set(CMAKE_CXX_STANDARD ...)`/`set(CMAKE_CXX_STANDARD_REQUIRED ...)`/`set(CMAKE_POSITION_INDEPENDENT_CODE ...)` block is the complete list of language settings — there is
no `CMAKE_BUILD_TYPE`, no `CMAKE_CXX_FLAGS`, no IPO/LTO, and no `target_compile_options`
anywhere in the tree. `--config Release` is accepted and ignored by Ninja and Make, so the
README's ship incantation produces a binary with no `-O` flag at all, on a tree whose
@@ -214,7 +216,7 @@ meaning the same edit covers both. **Those edits still get made in their shared
noted as shared** — a `#ifdef _WIN32` / `#else` that is right for both costs nothing extra
and does not require a mac. What is out is: macOS as a phase deliverable, any macOS
verification, the `swell-modstub.mm`-under-a-CXX-only-`project()` question (root
`CMakeLists.txt:26` is `LANGUAGES CXX`), signing and notarization, and the macOS-only
`CMakeLists.txt`'s `project(...)` call is `LANGUAGES CXX`), signing and notarization, and the macOS-only
half of L2-11 (`normalizeSlashes` under-folds on case-insensitive APFS — a real pre-existing
defect this phase surfaces and does not own).
@@ -283,7 +285,7 @@ Route B3a (Λ-D2) is the ruled route. It must be reached without the stub's own
1. **Never define `SWELL_LOAD_SWELL_DYLIB`.** Compile
`swell-modstub-generic.cpp` in its default branch — the same branch the extension already
uses (`src/app/CMakeLists.txt:9192`) — which exports `SWELL_dllMain(hInst, callMode,
uses (`src/app/CMakeLists.txt`'s `else()` (Linux) branch's `target_sources`/`target_compile_definitions` pair) — which exports `SWELL_dllMain(hInst, callMode,
GetFunc)` (`:135`) and calls `doinit` on the pointer it is handed. The whole file is
inside `#ifdef SWELL_PROVIDED_BY_APP` (`:21`), so the VST3 target must define that
symbol too; today it does not.
@@ -422,8 +424,8 @@ audits and verified in Λ-W3; T4 is verifiable on the current box.
**Goal.** The extension compiles and links under GCC/Clang, and when it refuses to load it
says why instead of vanishing.
**Surface boundary — owns:** `src/shell/panel/draw_kit.cpp` (`loadFont`, `:7077`),
`src/shell/actions/instrument_drop_win.cpp` (`writeTempPreset`, `:5061`), `src/app/main.cpp`
**Surface boundary — owns:** `src/shell/panel/draw_kit.cpp` (`loadFont`),
`src/shell/actions/instrument_drop_win.cpp` (`writeTempPreset`), `src/app/main.cpp`
(the `REAPERAPI_LoadAPI` failure branch only). **Does not own:** any `CMakeLists.txt`,
`panel_window.cpp`, or any `core/` file.
@@ -432,7 +434,7 @@ says why instead of vanishing.
**Do not add `windows.h`** (L2-01's stated direction). The family bits are advisory to
Windows' font mapper and meaningless to fontconfig.
- Replace `GetCurrentProcessId()` with a platform-neutral uniqueness source behind a guard;
the atomic counter at `:52` already carries the intra-process half (L2-02).
the atomic counter in `writeTempPreset` already carries the intra-process half (L2-02).
- On the load-failure branch, either switch `main.cpp` to `REAPERAPI_MINIMAL` plus an
explicit `WANT` list — the pattern `panel_window.cpp` and `panel_audition.cpp` already
use — or keep the full load and print the failure count via
@@ -507,8 +509,8 @@ only; the `WIN32` gate is Λ-W6-T1's), `README.md` and root `CLAUDE.md` §"Build
**Goal.** The docked bank panel opens on Linux, and if it ever fails to, it says so.
**Surface boundary — owns:** `src/shell/panel/panel_window.cpp` (the `CreateDialogParam`
call at `:135137`, the dialog proc's platform contract, the drop-accept opt-in at
`:145150`), `src/resource.rc`, `src/resource.h`, and — **under the resgen route only**
call and its `if (!g_panel.hwnd) return;` guard, both in `openPanel`, the dialog proc's platform contract, the drop-accept opt-in
also in `openPanel`), `src/resource.rc`, `src/resource.h`, and — **under the resgen route only**
one `target_sources` line in `src/app/CMakeLists.txt`'s `else()` branch plus a new
include-shim TU. **Does not own:** any other panel TU, `draw_kit`, or any CMake target
property.
@@ -685,7 +687,7 @@ as the Linux defaults, following SWELL's own no-fontconfig fallback list
(LiberationSans/DejaVuSans, LiberationMono/DejaVuSansMono) as precedent. **One code path**,
shared with macOS's eventual San Francisco/Menlo (Λ-D4: made in shared form, not verified).
The subtlety worth carrying into the work: `draw_kit.cpp:74`'s `if (!hf) return` guard does
The subtlety worth carrying into the work: `draw_kit.cpp`'s `loadFont`'s `if (!hf) return` guard does
**not** catch the failure mode here. SWELL's `CreateFont` always returns a non-null handle
even when the face never resolved — the failure is recorded internally as a null
`typedata`, not as a null return. So a wrong or missing face is not observable at the call
@@ -706,7 +708,7 @@ cosmetic or a readability regression. **Discharges:** L2-09.
**Goal.** The build's source list stops relying on every TU's own `#ifdef` discipline, and
the invariants Linux weakens are stated where a reviewer will read them.
**Surface boundary — owns:** the `target_sources` list in `src/app/CMakeLists.txt:851`
**Surface boundary — owns:** the `add_library(reaper_reasampler MODULE ...)` source list in `src/app/CMakeLists.txt`
(the *list*; the property blocks are Λ-W2-T2's), any new platform-sibling TU the sweep
showed was needed, `src/shell/actions/drag_out_win.h`'s invariant comment, and the
corresponding `src/shell/**/CLAUDE.md` invariant passages. **Does not own:** any behaviour
@@ -718,7 +720,7 @@ change in a shipped code path.
`arrange_drop_win.cpp` and `instrument_drop_win.cpp` are `_win`-suffixed for the surface
they serve, not for a platform dependency, and the audits found them portable by
inspection — confirm against the actual compile rather than re-inspecting.
- **L2-10** — make `drag_out_win.h:711` the doc a Linux reviewer is pointed at, and treat
- **L2-10** — make `drag_out_win.h`'s file-header invariant comment the doc a Linux reviewer is pointed at, and treat
"MOVE is structurally impossible" as a Windows-scoped claim. **The wording is Λ-F3's
ruling**; the edit is this track's regardless of which way it goes.
- **L2-11** — no Linux action. If the predicate is touched at all it becomes
@@ -808,7 +810,7 @@ modstub TU), and the three D5 passages in `src/core/instrument/CLAUDE.md`,
includes `<windows.h>` with no `SMTG_OS_*` guard; `linuxmain.cpp` exports `ModuleEntry`
and `ModuleExit`, **both mandatory** — the SDK's own loader refuses the module without
either. Both files are already vendored; this is a source swap plus a platform `if()`.
- **Split the `WIN32 AND EXISTS` conjunction** at `src/shell/instrument/CMakeLists.txt:9`.
- **Split the `WIN32 AND EXISTS` conjunction** in `src/shell/instrument/CMakeLists.txt`'s `if(WIN32 AND EXISTS ...)` gate.
The `EXISTS` half stays (a fresh clone with no VST3 slice must still configure); the
`WIN32` half becomes a Windows-or-Linux predicate.
- **B2** — the artifact becomes a directory:
@@ -1001,7 +1003,7 @@ the strict reading:
| File | Tracks | Nature |
|---|---|---|
| `src/app/CMakeLists.txt` | Λ-W2-T2 (property + platform blocks), Λ-W2-T3 (one `target_sources` line, **resgen route only**), Λ-W4-T3 (the source list), Λ-W5-T1 (the `install()` rule) | Four disjoint regions of one file. Λ-W2-T2 and Λ-W2-T3 are the only pair in the same wave; one line each. |
| `src/shell/panel/draw_kit.cpp` | Λ-W2-T1 (`loadFont`'s `CreateFont` args, `:73`), Λ-W4-T2 (the five call sites, `:154158`) | Different waves. |
| `src/shell/panel/draw_kit.cpp` | Λ-W2-T1 (`loadFont`'s `CreateFont` args), Λ-W4-T2 (`kitFontsInit`'s five `loadFont` call sites) | Different waves. |
| `src/shell/instrument/CMakeLists.txt` | Λ-W2-T2 (thread linkage), Λ-W6-T1 (gate, entry point, bundle, install), Λ-W7-T1 (one added TU) | Different waves. |
| `src/shell/panel/panel_window.cpp` | Λ-W2-T3 alone | **Deliberately not split.** The L2-06 diagnostic and the Λ-01 resource route are the same function; under the resource-id-0 route they are the same *line*. Splitting them would be semantic contention. |
| `src/shell/instrument/editor_platform.cpp` | Λ-W6-T2 (the refusal branch), Λ-W8-T1 (the real branch) | Different waves; the second replaces the first's computation without touching its call sites. |
@@ -1125,7 +1127,7 @@ carried. Nothing is gated on it before Λ-W5.
0, which creates an opaque child window, provided a `WNDPROC` returning `LRESULT` (cast to
`DLGPROC`) is passed instead of a real `DLGPROC`. The implementation confirms both halves —
`swell-dlg-generic.cpp` skips the resource lookup entirely when `resid` is 0. And
`IDD_BANK_PANEL` is precisely the case it was written for: `src/resource.rc:1822` is a
`IDD_BANK_PANEL` is precisely the case it was written for: `src/resource.rc`'s `IDD_BANK_PANEL` dialog block is a
`WS_CHILD` dialog with an empty `BEGIN`/`END` body and zero controls, whose own header
comment says "the bank_panel shell owns every pixel and draws the sample grid with LICE in
`WM_PAINT`". **Taking this route deletes the entire resgen pipeline from the non-Windows
+2 -2
View File
@@ -1117,7 +1117,7 @@ Addendum is the *why*; those are the *what/how*.
**Framing.** Folds one more control into the S-VIEW redesign: a **visual velocity → amp
transfer-curve editor**. Today the engine maps velocity to gain *linearly* (`velocityGain_ =
velocity / 127.0`, `sampler_core.cpp:261`), applied once at note-on in `Voice::start()`. Daniel
velocity / 127.0`, `Voice::start()`), applied once at note-on in `Voice::start()`. Daniel
wants that mapping to become an **editable transfer curve** — a bezier from a default flat line to
an arbitrary multi-point curve — so velocity dynamics are fully shapeable per sound.
@@ -1181,7 +1181,7 @@ a LICE shell that draws handles and routes the mouse).
evaluation is called at note-on, not per frame (see call 4).
4. **Voice-engine application point → `Voice::start()`, replacing the linear `velocity/127`.**
Confirmed from source: `sampler_core.cpp:261` computes `velocityGain_ = velocity / 127.0` **once
Confirmed from source: `Voice::start()` computes `velocityGain_ = velocity / 127.0` **once
at note-on** inside `Voice::start()`; the per-frame render path (`advanceFrame`, line 408:
`gain = amp * velocityGain_`) then just multiplies the cached scalar. So the transfer curve
slots in at exactly one line: `velocityGain_ = curve.eval(velocity)` at note-on — **off the
+1 -1
View File
@@ -199,7 +199,7 @@ it — three small pure additions and one bounded seam:
1. **A render destination that is not the bank.** `OfflineRenderBackend::capture`
derives its output path from `deriveBankPaths(projectDir, …)` unconditionally
(`capture.cpp:417`) and points `RENDER_FILE` at the bank folder. Nothing about
(`capture.cpp`'s `OfflineRenderBackend::capture`) and points `RENDER_FILE` at the bank folder. Nothing about
that is parameterized. The alternative — render into the bank and then move the
file out — was rejected: it puts a transient, unindexed, unowned file inside the
folder prune enumerates, which is exactly the file class the ownership rule exists
+3 -3
View File
@@ -45,7 +45,7 @@ below:
Two sharp edges follow directly and recur throughout this note:
- **The `STABLE_FOREVER_STRING` command-id contract** (CLAUDE.md; `main.cpp:41`,
- **The `STABLE_FOREVER_STRING` command-id contract** (CLAUDE.md; `app_version.h`'s `commandIdPrefix()`,
prefix `CEREBELLUM_REASAMPLER_`). Command-id strings are minted once and **never
changed after shipping** — user keybindings key off them. Two coexisting binaries
that register the *same* id strings collide in REAPER's Actions list.
@@ -67,9 +67,9 @@ allowed to touch.
## What we have today
- No version anywhere. `CMakeLists.txt:2` is `project(reaper_reasampler LANGUAGES
- No version anywhere. `CMakeLists.txt` is `project(reaper_reasampler LANGUAGES
CXX)` — no `VERSION`. The binary announces itself only as `"ReaSampler loaded.\n"`
to the console (`main.cpp:960`). There is no number a user, a bug report, or a
to the console (`main.cpp`). There is no number a user, a bug report, or a
future migration can key off.
- The natural user-visible readout already exists: the docked LICE bank panel, and
the console (`ShowConsoleMsg`). A version has cheap homes; none is wired.
+172
View File
@@ -0,0 +1,172 @@
# DAW verification — bank-package transfer across machines
What a DAW pass must establish for `.rsbank` export and import, and the exact strings or
counts to read off. The unit corpus (`tests/fixtures/package_compat/`) already proves the
version ladder, the truncation verdicts and the hostile-name refusals against frozen
bytes. **Nothing below is covered by it**: every cell here depends on a real REAPER
session, a real file dialog, or a genuine second machine.
**Build to use.** Release, installed into `UserPlugins/`, REAPER restarted — extensions
load at startup only. Note the version the *About*/version action reports; §5 needs it.
**Machines to use.** Two: **A** (the source) and **B** (the destination). B must be a
different machine, or at minimum a different user account with its own REAPER resource
path and its own projects folder — the point is that no absolute path from A can resolve
on B. A USB stick, a network share, or a cloud folder are all acceptable transports.
**Projects to use.** On A: one **saved** project with a bank holding at least **three**
samples, at least one of them audibly distinct from the others, and at least one whose
display name carries a non-ASCII character (e.g. `Café hit`). On B: one **saved**,
otherwise empty project.
---
## 1. Export writes one file and touches nothing else
On A, right-click the bank's header in the docked panel → **Export as package...** (or
run *ReaSampler: export active bank as package*). Accept the suggested file name.
Read off:
- The console shows `ReaSampler export: wrote 3 entry/entries (N bytes) to <path>`, with
the entry count matching the bank.
- A single `.rsbank` file exists at that path. **No `.rsbanktmp` sibling remains** — a
leftover temp file means the atomic rename did not complete.
- The bank's card count, the bank folder's file count, and the project's dirty flag are
all **unchanged**. An export writes no ext state and opens no undo point, so REAPER
must not consider the project modified by it alone.
- Nothing was added to the arrange view.
## 2. The transfer itself — the claim no unit test can make
Copy the `.rsbank` to B by whatever transport you chose. Do **not** copy the project, the
bank folder, or anything else.
On B, open the empty saved project. Panel bank menu → **Import bank package...** (or run
*ReaSampler: import bank package (.rsbank)*), and choose the transferred file.
Read off:
- A message box: `Imported 3 sample(s) into a new bank: "<bank name>".`
- The console block repeats that line and ends with `One undo removes the imported bank
and its entries. It does NOT delete the imported files ...`.
- The panel shows a **new** bank with the same display name and the same number of cards,
**in the same order** as on A.
- B's bank folder holds three new files. The non-ASCII display name from A renders
correctly on the card — a mangled name here means the UTF-8 path/name conversion broke
in transit.
- **Audition each card.** They must sound like their counterparts on A. This is the whole
claim: the audio survived a machine boundary with no shared path.
- Press **Ctrl-Z once**. The imported bank and its entries disappear in one step. The
three files remain in B's bank folder (that is stated in the console block above, and is
the designed behaviour — a prune reclaims them). Redo to continue.
## 3. Re-importing the same package never overwrites
Still on B, import the **same** file a second time.
Read off:
- A second new bank appears, named with a suffix (`<bank name> 2`), and the box's
`(a bank named "<bank name>" already exists in this project)` clause appears in the
console block.
- B's bank folder now holds **six** files, not three. The console reports
`3 file(s) landed under a freshly minted name (the package's own name was already taken
in the bank folder). An existing bank file is never overwritten.`
- The first imported bank's cards still audition correctly — nothing was replaced under it.
## 4. Round trip back to the source
On B, export the imported bank (§1) to a second `.rsbank`. Carry it back to A and import
it into A's original project.
Read off:
- The import succeeds and lands as a new bank beside the original.
- The original bank on A is untouched: same card count, same names, same audio.
- Compare the two `.rsbank` files' **sizes**. They will usually differ — entry names,
sample ids and the export timestamp are all legitimately re-minted across a trip. The
payload bytes are what must survive, and that half is closed by
`tests/test_package_round_trip.cpp` against frozen bytes; do **not** treat a size
difference here as a defect.
## 5. The too-new refusal, with the message read verbatim
This is the direction a user hits when a collaborator is ahead of them, and the message is
the only actionable output. Produce it by hand:
1. Copy the `.rsbank` from §1 to a scratch name.
2. Open the copy in a hex editor. Bytes 03 are `RSBK`; bytes 47 are `formatVersion`
little-endian; bytes **811** are `minReaderVersion` little-endian.
3. Change byte **8** from `01` to `02`, and byte **4** from `01` to `02` (a writer cannot
require a reader newer than the format it wrote — leaving `formatVersion` at 1 makes
the file incoherent and it will be refused as malformed instead, which is a different
cell). Save.
4. Import the edited copy.
Read off — the message box, all four lines:
```
Cannot import this bank package.
It was written by ReaSampler <the version noted at the top> and needs package format 2 or newer.
This build (<the same version>) reads package format 1.
Nothing was imported. Install ReaSampler <the same version> or newer and try again.
```
- The writer version named is the one **this** build stamped in §1 (the hex edit does not
touch the semver), so the second and fourth lines will name your own version. That is
expected — what is being verified is that all three facts are present and the box
appears at all.
- **No** new bank, **no** new files in the bank folder, **no** undo point.
## 6. The truncated-download refusal is a different message
Copy the §1 package again and delete the last few hundred bytes (any hex editor, or
`head -c` / `fsutil` — the exact count does not matter as long as the file is shorter).
Import it.
Read off:
- The message box reads exactly: `This file is not a readable bank package (corrupt or
truncated). Nothing was imported.`
- It is **not** the §5 message. Crossing these two is the failure this cell exists to
catch — "install a newer build" does not fix a partial download.
- No new bank, no new files.
## 7. Corruption in the middle is caught before anything lands
Copy the §1 package again and flip a single byte **well past the halfway point** (inside a
payload, not the header). Import it.
Read off:
- The message box names the offending entry:
`This bank package is damaged (entry "<name>" failed its integrity check). Nothing was
imported.`
- The bank folder gained **no** files at all — not even the entries before the damaged
one. Verification runs to completion before the first write, so a damaged package costs
no rollback.
## 8. The unsaved-project refusals
- On B, File → New Project (do not save). Try to import. Read off:
`Save the project before importing a bank package -- an unsaved project has no bank
folder to import into.` The file picker must **not** have opened first.
- On A, in an unsaved project with no bank, try to export. Read off the console:
`ReaSampler export: save the project first -- an unsaved project has no bank folder to
read from.`
## 9. Drag-and-drop reaches the same verb
On B, drag a `.rsbank` from the file manager onto the docked ReaSampler panel.
Read off: the same import box as §2, and the same new bank. A `.rsbank` is a whole bank,
not audio — it must never land as an item in the arrange view.
---
## Recording the result
For each section, record **pass**, **fail with the string actually seen**, or **not
exercised**. §2 and §4 are the load-bearing ones: they are the only cells in this document
that involve a real machine boundary, and no unit test can stand in for them.
+11 -1
View File
@@ -16,6 +16,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/capture/render_selection.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_isolation.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_bounds_gate.cpp
${REASAMPLER_SRC_DIR}/shell/capture/render_in_place.cpp
${REASAMPLER_SRC_DIR}/shell/capture/realtime_lifecycle.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_shell.cpp
${REASAMPLER_SRC_DIR}/shell/capture/capture_realtime_finalize.cpp
@@ -45,18 +46,27 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/design_view_actions.cpp
${REASAMPLER_SRC_DIR}/shell/actions/bank_actions.cpp
${REASAMPLER_SRC_DIR}/shell/actions/prune_action.cpp
${REASAMPLER_SRC_DIR}/shell/actions/package_export_action.cpp
${REASAMPLER_SRC_DIR}/shell/actions/ingest.cpp
${REASAMPLER_SRC_DIR}/shell/actions/arrange_drop_win.cpp
${REASAMPLER_SRC_DIR}/shell/actions/drag_out_win.cpp
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths capture_name peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys solo_cache insert_plan render_settings render_window track_topology batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage bake_wire resample_name export_bank package_pickers)
# NOT linked here, deliberately: sampler_core / pitch_shift / the filter / limiter. The
# instrument renders its own bake in its own process, which is what keeps the extension's
# link graph free of the voice engine a link edge to it here means the design drifted.
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# Bank-package import: the promptless verb plus its action skin. Kept as its own
# appended block rather than merged into the lists above, so the two package
# directions stay textually independent.
target_sources(reaper_reasampler PRIVATE
${REASAMPLER_SRC_DIR}/shell/package/import_bank.cpp
${REASAMPLER_SRC_DIR}/shell/actions/package_import_action.cpp)
target_link_libraries(reaper_reasampler PRIVATE import_landing package_pickers)
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
# configs, since REAPER dlopen's any reaper_* module and the two channels' artifacts load
# side-by-side. LIBRARY_OUTPUT_DIRECTORY pins the module to the top of the build tree even
+15
View File
@@ -26,11 +26,14 @@
#include "shell/actions/action_registry.h" // the registration table
#include "shell/actions/bank_actions.h" // multi-bank action family
#include "shell/actions/design_view_actions.h" // Design View action family
#include "shell/actions/package_export_action.h" // bank-package export action body
#include "shell/actions/package_import_action.h" // bank-package import action body
#include "core/wire/bake_wire.h" // kBakeActionSuffix (the shared action id)
#include "shell/capture/bake_land.h" // resample-bake landing action body
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
#include "shell/capture/render_in_place.h" // render-in-place action body
#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
#include "shell/persist/session.h" // ReaSamplerSession
@@ -88,7 +91,10 @@ static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session)
static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); }
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); }
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
static void RunExportBankPackage(int) { reasampler::doBankPackageExport(g_session, g_session.book().activeBankId()); }
static void RunImportBankPackage(int) { reasampler::doImportBankPackage(g_session); }
static void RunShowVersion(int) {
// On-demand only — no unconditional startup print (routine console chatter pops
// the console window).
@@ -134,12 +140,21 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
&RunCancelRealtime});
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
&RunRecaptureFromSource});
// A RENDER_*, not a CAPTURE_*: the id is permanent and is the most durable
// statement the codebase makes about which pillar a feature belongs to.
rows.push_back({"RENDER_TRACK_IN_PLACE",
"render selected track to a new track (source moves to Design)",
&RunRenderTrackInPlace});
// Invoked by a ReaSampler 9000 instance over the VST3 host bridge (and bindable, so a
// stranded request can be landed by hand). The suffix is the wire contract itself —
// core/wire/bake_wire owns the spelling both artifacts read.
rows.push_back({reasampler::wire::kBakeActionSuffix,
"land pending ReaSampler 9000 resample bake",
&RunResampleBake});
rows.push_back({"EXPORT_BANK_PACKAGE", "export active bank as package",
&RunExportBankPackage});
rows.push_back({"IMPORT_BANK_PACKAGE", "import bank package (.rsbank)",
&RunImportBankPackage});
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
return rows;
+1
View File
@@ -13,6 +13,7 @@ add_subdirectory(capture)
add_subdirectory(tracking)
add_subdirectory(reclaim)
add_subdirectory(version)
add_subdirectory(package)
add_subdirectory(view)
add_subdirectory(ui)
add_subdirectory(instrument)
+7 -2
View File
@@ -51,8 +51,8 @@ Detail specific to these pure modules:
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition``NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates.
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode``RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal).
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode``RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the one bounds mode a capture hands its window over on (`kRenderBoundsTimeSelection`) and the tail bit paired with it (`kTailFlagTimeSelection`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal).
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. It also owns the one short-render diagnostic: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched on the retired custom-bounds mode, quoted by a refusal as a count coincidence and nothing more) and `isOnMillisecondGrid`, the whole-millisecond tolerance that count depends on.
- `track_topology` — the REAPER-free folder arithmetic over a project's flat `I_FOLDERDEPTH` delta list: `directChildIndices` names a folder parent's DIRECT children, the set `shell/capture/render_isolation` silences so a ranged item capture does not print its track's children. Grandchildren are excluded by construction — they reach the parent only through the child that owns them.
- `tail_control` — the REAPER-free logic behind the docked `bank_panel`'s tail-mode toggle: the cycle order (None → Auto → Manual → None), the Manual-length clamp/scroll-wheel fine-adjust (`clampManualMs`/`adjustManualMs`, 250 ms/notch, 2000 ms default), the toggle's label text (e.g. "Tail: Manual 2.0s"), and the `TailSetting` JSON round-trip persist stores per-project.
@@ -79,6 +79,11 @@ Detail specific to these pure modules:
that with a transient silencing (`shell/capture/render_isolation`) whose child-set
walk lives here in `track_topology`; the item-vs-track asymmetry behind it is in
`src/shell/capture/CLAUDE.md`.
- **The custom-time-bounds field floors the render window to the millisecond; the
time selection does not.** Both observations and why only one bounds mode is
reachable: `render_settings.h`'s `kRenderBoundsTimeSelection` — the one narrative
home; this bullet is a pointer, not a retelling. Do not reintroduce
`RENDER_BOUNDSFLAG=0`.
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
FX-bypass-around-render mechanism, never by a render bit.
+15
View File
@@ -86,4 +86,19 @@ CaptureName composeCaptureName(const CaptureNameInputs& in) {
return out;
}
std::string captureTrackName(const std::string& sourceName) {
const std::string prefix(kCaptureTrackPrefix);
// A source with no readable name yields the bare word rather than a trailing
// space; both spellings are fixed points, which is what makes the whole function
// one (a track named exactly "Capture" must not become "Capture Capture"). Read
// from kCaptureTrackPrefixBare rather than chopped off prefix, so the two names
// can't drift out of sync with each other (both expand from the same header token).
const std::string bare = kCaptureTrackPrefixBare;
if (sourceName.empty()) return bare;
if (sourceName == bare) return sourceName;
if (sourceName.rfind(prefix, 0) == 0) return sourceName;
return prefix + sourceName;
}
} // namespace reasampler::capture
+22
View File
@@ -59,4 +59,26 @@ std::string formatCaptureStamp(const CaptureStamp& stamp);
CaptureName composeCaptureName(const CaptureNameInputs& in);
// The single source of truth for the word itself — kCaptureTrackPrefixBare and
// kCaptureTrackPrefix below both expand from this one token, so editing it can never
// desync captureTrackName's "no readable source name" bare-word fallback from the
// separator-terminated prefix it is derived from.
#define REASAMPLER_CAPTURE_TRACK_WORD "Capture"
// The bare word behind kCaptureTrackPrefix, needed by captureTrackName's
// no-readable-source-name fallback.
inline constexpr const char* kCaptureTrackPrefixBare = REASAMPLER_CAPTURE_TRACK_WORD;
// Prefixed onto a source track's name to name the track a render-in-place created.
// A display convention, not a persisted key — unlike a lane prefix or an action-id
// suffix, changing it later strands nothing.
inline constexpr const char* kCaptureTrackPrefix = REASAMPLER_CAPTURE_TRACK_WORD " ";
// The new track's name for a render of `sourceName`. IDEMPOTENT — a fixed point on
// its own output, so a second render over a result track yields "Capture MONEY"
// again rather than "Capture Capture MONEY". A counter suffix is deliberately not
// offered: REAPER does not uniquify track names either, and what distinguishes two
// renders of one source is their position, not their name.
std::string captureTrackName(const std::string& sourceName);
} // namespace reasampler::capture
+22 -12
View File
@@ -45,16 +45,25 @@ std::string sanitizeStem(const std::string& baseName) {
return out;
}
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag) {
const std::string dir = normalizeSlashes(projectDir);
RenderPaths deriveRenderPaths(const std::string& absoluteDir,
const std::string& baseName,
const std::string& uniqueTag) {
std::string stem = sanitizeStem(baseName);
if (!uniqueTag.empty()) {
stem += "_" + sanitizeStem(uniqueTag);
}
const std::string fileName = stem + ".wav";
RenderPaths r;
r.fileStem = stem; // stem only — REAPER appends the extension
r.fileName = stem + ".wav";
r.absoluteDir = normalizeSlashes(absoluteDir);
return r;
}
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag) {
const std::string dir = normalizeSlashes(projectDir);
// Precondition: caller must resolve a non-empty project directory — an
// empty one would otherwise fall back to a bare relative path (forbidden).
@@ -62,18 +71,19 @@ BankPaths deriveBankPaths(const std::string& projectDir,
// ignores it fails at the render/stat step, not silently onto CWD.
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
const RenderPaths r = deriveRenderPaths(
dir.empty() ? std::string{} : dir + "/" + kBankSubfolder, baseName, uniqueTag);
BankPaths p;
p.fileStem = stem; // stem only — REAPER appends extension
p.fileName = fileName;
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
p.absoluteDir = dir.empty() ? std::string{}
: dir + "/" + kBankSubfolder;
p.fileStem = r.fileStem;
p.fileName = r.fileName;
p.relativePath = bankRelativeForName(r.fileName);
p.absoluteDir = r.absoluteDir;
return p;
}
std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {};
// Same expression deriveBankPaths uses, so the two spellings can't drift.
return std::string(kBankSubfolder) + "/" + fileName;
}
+23 -3
View File
@@ -36,9 +36,29 @@ std::string normalizeSlashes(const std::string& path);
// "capture" if nothing usable remains. Deterministic.
std::string sanitizeStem(const std::string& baseName);
// Derives the bank paths for one capture: baseName is the sanitized file-stem
// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
// repeated captures don't collide. Produces "<stem>[_<tag>].wav".
// Where one render writes, with no index spelling at all: the directory REAPER is
// told to render into plus the stem/file name it produces there. `absoluteDir` is
// taken as given (normalized only) rather than derived, because a render that never
// enters the bank has no bank subfolder to append — the render-in-place verb points
// this at the project's own recording path.
struct RenderPaths {
std::string absoluteDir; // RENDER_FILE (forward slash, no trailing slash)
std::string fileName; // <stem>.wav
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
};
// The file-stem spelling for one render: baseName is the sanitized file-stem source,
// uniqueTag an optional sanitized disambiguator (timestamp/counter) so repeated
// renders don't collide. Produces "<stem>[_<tag>].wav". THE one owner of that
// spelling — deriveBankPaths is expressed over it, and bankRelativeForName depends
// on the bank's spelling never drifting from it.
RenderPaths deriveRenderPaths(const std::string& absoluteDir,
const std::string& baseName,
const std::string& uniqueTag);
// Derives the bank paths for one capture: the same stem spelling as
// deriveRenderPaths, in the bank subfolder, plus the project-relative path the
// index stores.
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag);
+2 -2
View File
@@ -30,7 +30,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
// postprocessing bit clear. A fixed-threshold trim scales/limits/fades
// nothing, so identical requests trim at the identical sample -> holds
// the bit-identical-repeats invariant.
t.tailFlag = kTailFlagCustomBounds;
t.tailFlag = kTailFlagTimeSelection;
t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd;
t.trimEnd = autoTrimEndRatio();
@@ -38,7 +38,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
case TailMode::Manual:
// Clamped to the cap regardless of source; negative floors to 0.
t.tailFlag = kTailFlagCustomBounds;
t.tailFlag = kTailFlagTimeSelection;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
+27 -11
View File
@@ -27,17 +27,34 @@ inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor e
// render wet; the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// --- Render bounds mode -------------------------------------------------------
//
// A capture hands its window over on RENDER_BOUNDSFLAG=2 — the project's own TIME
// SELECTION (value verbatim, header ~3042), written through GetSet_LoopTimeRange.
//
// Custom time bounds (RENDER_BOUNDSFLAG=0, RENDER_STARTPOS/RENDER_ENDPOS, header
// ~3045-3046) must NOT be reintroduced: REAPER resolved a custom-bounds window on a
// whole-millisecond grid AT RENDER TIME, floored the end, wrote the floored value back
// over RENDER_ENDPOS, and rendered exactly the floored frame count — twice, to the
// frame. Re-rendering on this mode came back exact on both edges, including a start
// carrying a sub-millisecond remainder, which is what locates the floor in the
// custom-bounds field rather than downstream in the render engine. This is the one
// narrative home for that; other sites point here.
inline constexpr int kRenderBoundsTimeSelection = 2;
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
//
// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG,
// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE
// (verbatim, header ~3051): &32768 = trim ending silence (Auto path);
// &(4<<16) = disable all render postprocessing (None/Manual path).
// RENDER_NORMALIZE (verbatim, header ~3051): &32768 = trim ending silence (Auto
// path); &(4<<16) = disable all render postprocessing (None/Manual path).
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
inline constexpr int kTailFlagNone = 0;
inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047
inline constexpr int kTailFlagNone = 0;
// RENDER_TAILFLAG's bits are keyed PER BOUNDS MODE (header ~3047): &4 is the
// time-selection mode's bit, the pair of kRenderBoundsTimeSelection above. A tail set
// under a different mode's bit renders no tail at all, so these two move together.
inline constexpr int kTailFlagTimeSelection = 4;
// Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND
// ratio derives from this dB, never the reverse). Daniel-set.
@@ -65,7 +82,7 @@ enum class TailMode {
// normalize bit is set (Auto). The backend reads these straight onto
// GetSetProjectInfo.
struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or the bounds mode's bit)
double tailMs = 0.0; // RENDER_TAILMS
int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
@@ -100,12 +117,11 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
// bounds refusal: the two ways a render can miss its window — a source that
// derives its own bounds (selected items, razor edits) versus a time-bounded
// render that came up short — are indistinguishable from a frame count alone,
// and naming the source is what tells them apart in a bug report. Quoted verbatim
// in docs/VERIFICATION.md, which asks for this exact line back.
// and naming the source is what tells them apart in a bug report.
//
// MasterMix and TimeSelection deliberately answer the SAME words: they map to the
// same RENDER_SETTINGS value and every capture renders custom-time-bounded, so
// naming them apart would assert a render distinction that does not exist.
// same RENDER_SETTINGS value and render identically, so naming them apart would
// assert a render distinction that does not exist.
const char* renderSourceLabel(SourceMode mode);
// --- Capture scope: the FX-scope invariant ------------------------------------
+17
View File
@@ -15,6 +15,13 @@ long long frameIndexAt(double seconds, int sampleRate) {
return std::llround(seconds * static_cast<double>(sampleRate));
}
// See the header for why whole milliseconds get a tolerance and why it is this small.
double floorToMilliseconds(double seconds) {
const double ms = seconds * 1000.0;
if (isOnMillisecondGrid(seconds)) return std::nearbyint(ms) / 1000.0;
return std::floor(ms) / 1000.0;
}
} // namespace
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate) {
@@ -41,4 +48,14 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd,
&& frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate);
}
bool isOnMillisecondGrid(double seconds) {
const double ms = seconds * 1000.0;
return std::fabs(ms - std::nearbyint(ms)) < 1e-6;
}
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
int sampleRate) {
return frameCountFor(startSeconds, floorToMilliseconds(endSeconds), sampleRate);
}
} // namespace reasampler::capture
+35 -5
View File
@@ -1,7 +1,8 @@
#pragma once
// render_window — pure frame arithmetic for a capture's requested window: the
// frame count a project-time range occupies, and whether a render whose bounds
// come from the selected items' own extent already prints that window.
// render_window — pure frame arithmetic for a capture's requested window: the frame
// count a project-time range occupies, whether a render whose bounds come from the
// selected items' own extent already prints that window, and the one diagnostic a
// refused render quotes — whether its shortfall matches a millisecond-floor coincidence.
// NO REAPER types; unit-tested by tests/test_render_window.cpp.
namespace reasampler::capture {
@@ -25,8 +26,7 @@ long long frameCountFor(double startSeconds, double endSeconds, int sampleRate);
// end edge by DIFFERENT conventions can legitimately sit TWO frames from this answer
// (tests/test_render_window.cpp pins both facts). Which model REAPER uses is
// unverified, so a refusal one or two frames wide may be this gate's fault rather than
// the render's — the open DAW question in docs/VERIFICATION.md §Capture range and
// bounds. Widening past one frame retires the exact-bounds invariant rather than
// the render's. Widening past one frame retires the exact-bounds invariant rather than
// relaxing it, and is not a fix to reach for before that question is answered.
bool renderHonoredBounds(long long expectedFrames, long long actualFrames);
@@ -42,4 +42,34 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd,
double itemStart, double itemEnd,
int sampleRate);
// --- Diagnostics: where a short render lost its frames ------------------------
// The frames this window would hold if its END were resolved on a whole-millisecond
// grid, floored, instead of exactly. That is what REAPER's offline render did on the
// retired custom-time-bounds mode (render_settings.h's kRenderBoundsTimeSelection states
// the whole observation): two live short renders (48 kHz, TailMode::None) printed this
// count to the frame. Kept as the refusal's shape check — a refused render matching it
// says the floor is back, on a mode that was measured escaping it.
//
// Still a DESCRIPTION, never a request: nothing renders from this number and no capture
// path asks for it — a refusal quotes it to say the shortfall has the known shape, which
// is not the same as proving that this particular render took it. Whole-millisecond values
// are recognized within a nanosecond, because a decimal millisecond is not always one
// in binary (1.007 * 1000 lands just below 1007) and a bare floor would drop a
// millisecond from a window already on the grid. A nanosecond is far under one frame
// at any rate we render, so a real sub-millisecond remainder still floors.
//
// The tolerance is ours, not REAPER's: on a `1.007`-class grid point, a REAPER floor
// that does NOT carry the same epsilon would miss this shape entirely, and a real
// floored render would then read as an unmatched short render rather than the known one
// — silence here is not proof the floor didn't happen (docs/TODO.md records why this
// premise needs a DAW measurement before anything is built on it).
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
int sampleRate);
// True when `seconds` sits on a whole-millisecond boundary, under the nanosecond
// tolerance msFlooredEndFrameCount depends on and for the reason stated there. Public so
// that premise is testable directly rather than only through the count it feeds.
bool isOnMillisecondGrid(double seconds);
} // namespace reasampler::capture
+34
View File
@@ -25,4 +25,38 @@ std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
return children;
}
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex) {
const int count = static_cast<int>(folderDepths.size());
if (count == 0) return SiblingPlacement{};
const int src = srcIndex < 0 ? 0 : (srcIndex >= count ? count - 1 : srcIndex);
// levels[i] is track i's absolute nesting depth; levels[count] is the depth the
// list closes at (0 in a well-formed project). Negative is unrepresentable, so a
// malformed over-closing delta clamps here rather than propagating.
std::vector<int> levels(static_cast<std::size_t>(count) + 1, 0);
for (int i = 0; i < count; ++i) {
const int next = levels[static_cast<std::size_t>(i)] +
folderDepths[static_cast<std::size_t>(i)];
levels[static_cast<std::size_t>(i) + 1] = next < 0 ? 0 : next;
}
const int L = levels[static_cast<std::size_t>(src)];
int p = src + 1;
if (folderDepths[static_cast<std::size_t>(src)] >= 1) {
p = count; // an unterminated folder swallows the rest of the list
for (int j = src + 1; j <= count; ++j) {
if (levels[static_cast<std::size_t>(j)] == L) { p = j; break; }
}
}
SiblingPlacement out;
out.insertIndex = p;
out.precedingIndex = p - 1;
out.precedingDepth = L - levels[static_cast<std::size_t>(p - 1)];
out.newDepth = levels[static_cast<std::size_t>(p)] - L;
return out;
}
} // namespace reasampler::capture
+35 -3
View File
@@ -1,8 +1,8 @@
#pragma once
// track_topology — pure folder arithmetic over a project's track list: which tracks
// are the DIRECT children of a folder parent, derived from the I_FOLDERDEPTH deltas
// alone. NO REAPER types (the shell reads the deltas); unit-tested by
// tests/test_track_topology.cpp.
// are the DIRECT children of a folder parent, and where a new SIBLING of a given
// track goes, both derived from the I_FOLDERDEPTH deltas alone. NO REAPER types
// (the shell reads the deltas); unit-tested by tests/test_track_topology.cpp.
#include <vector>
@@ -21,4 +21,36 @@ namespace reasampler::capture {
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
int parentIndex);
// Where a new track goes so it is a SIBLING of `srcIndex` — same nesting level, same
// folder — and the two I_FOLDERDEPTH writes that put it there.
struct SiblingPlacement {
int insertIndex = 0; // the index the new track occupies after insertion
// The track that will PRECEDE the new one (insertIndex - 1), and its rewritten
// delta. -1 only for a degenerate empty list, where there is nothing to write.
int precedingIndex = -1;
int precedingDepth = 0;
int newDepth = 0; // the new track's own I_FOLDERDEPTH
};
// Both naive answers are audibly wrong, which is why this is arithmetic and not
// `srcIndex + 1`: inserting straight after a folder PARENT makes the new track that
// folder's first child (its audio re-enters the parent's FX and fader), and inserting
// straight after the folder's LAST track steals that track's closing delta and drops
// the new one outside the folder entirely (its audio bypasses the folder bus).
//
// Levels are absolute nesting depths recovered from the deltas (level[0] = 0,
// level[i+1] = level[i] + depth[i]). A folder parent's insert point is the first
// following track back at the source's own level — i.e. after the whole folder;
// everything else inserts directly below the source. On a well-formed delta list
// (one whose deltas sum to zero) the two writes preserve the total delta sum, so no
// track after the insertion changes level — the malformed case below does not carry
// that guarantee; the clamp keeps the result legal, not level-preserving.
//
// A malformed list (deltas not summing to zero, an out-of-range srcIndex) CLAMPS to
// the nearest legal placement rather than asserting: the failure mode of a corrupt
// project must be a track at the wrong nesting level, never a crash.
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex);
} // namespace reasampler::capture
+18
View File
@@ -6,6 +6,7 @@
#include <cstdio> // std::snprintf (hash hex render)
#include <cstring> // std::memcpy, std::memcmp
#include <utility> // std::move
namespace reasampler::capture {
@@ -300,6 +301,23 @@ MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes) {
return out;
}
CollapsedWav applyMonoCollapse(std::vector<std::uint8_t> bytes) {
CollapsedWav out;
MonoCollapse collapse = collapseToMono(bytes);
if (collapse.collapsed) {
const WavLayout rebuilt = parseWavLayout(collapse.bytes);
if (rebuilt.valid) {
out.bytes = std::move(collapse.bytes);
out.layout = rebuilt;
out.collapsed = true;
return out;
}
}
out.bytes = std::move(bytes);
out.layout = parseWavLayout(out.bytes);
return out;
}
std::string monoCollapseSuffix(MonoCollapseOutcome outcome) {
switch (outcome) {
case MonoCollapseOutcome::Declined: return {};
+16
View File
@@ -117,6 +117,22 @@ struct MonoCollapse {
// including the bext/source-position consequence beyond hashing.
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes);
// A buffer after the collapse has had its say, PAIRED with the parse of the bytes
// actually returned — so a caller that hashes `bytes`, reads a channel count off
// `layout` and then writes `bytes` cannot describe one buffer while writing another.
struct CollapsedWav {
std::vector<std::uint8_t> bytes; // the rebuilt 1-channel WAV, or the input verbatim
WavLayout layout; // the parse OF `bytes`
bool collapsed = false;
};
// `collapseToMono` over a whole buffer, for a caller that goes on to hash and measure
// the result rather than rewrite a file (`shell/capture`'s collapseCapturedFileToMono is
// the file-side path over the same predicate). Takes the buffer by value: a decline hands
// those same bytes straight back. A rebuild that does not parse back is discarded rather
// than returned, so an invalid `layout` can only ever mean the INPUT was not a usable WAV.
CollapsedWav applyMonoCollapse(std::vector<std::uint8_t> bytes);
// How applying the collapse to a captured FILE ended. `Declined` is collapseToMono's own
// "nothing to do"; `Failed` is a read that never happened or a warranted rewrite that did
// not land. The capture is intact and correctly measured in every case — only the report
+15 -3
View File
@@ -3,6 +3,8 @@
#include <algorithm>
#include <unordered_set>
#include "core/util/ascii_ws.h"
// bank_book implementation — the registry RULES half: construction, pool
// privileges, bank lifecycle, active bank, sample movement/removal, slot order,
// and the reference queries. The JSON round-trip half lives in bank_book_json.cpp,
@@ -76,9 +78,8 @@ void BankBook::normalizeOrdinals() {
// one folding rule shared with bank_book_json.cpp's parse-time coalesce.
std::string BankBook::nameKey(const std::string& s) {
std::size_t b = 0, e = s.size();
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
while (b < e && isWs(s[b])) ++b;
while (e > b && isWs(s[e - 1])) --e;
while (b < e && util::isAsciiWs(s[b])) ++b;
while (e > b && util::isAsciiWs(s[e - 1])) --e;
std::string out;
out.reserve(e - b);
for (std::size_t i = b; i < e; ++i) {
@@ -117,6 +118,17 @@ bool BankBook::createBank(const std::string& id, const std::string& displayName)
return true;
}
// Runs behind displayNameTaken so the probe and the create/rename check can never
// disagree about what "already used" means. exceptId is deliberately "" — no bank can
// carry an empty id, so nothing is excluded from the scan.
std::string BankBook::uniqueDisplayName(const std::string& seed) const {
if (!displayNameTaken(seed, /*exceptId=*/std::string{})) return seed;
for (int n = 2;; ++n) {
std::string candidate = seed + " " + std::to_string(n);
if (!displayNameTaken(candidate, /*exceptId=*/std::string{})) return candidate;
}
}
bool BankBook::renameBank(const std::string& id, const std::string& displayName) {
if (id == kPoolBankId) return false; // pool is un-renamable
Bank* b = bank(id);
+11
View File
@@ -100,6 +100,17 @@ public:
// no-op success.
bool renameBank(const std::string& id, const std::string& displayName);
// The first name in the sequence `seed`, "seed 2", "seed 3", … whose fold is free
// in this book — what a caller that must not be rejected (the package import) asks
// for before createBank. First-FREE-ascending, not highest-plus-one, so it fills a
// gap ("Drums" + "Drums 3" present yields "Drums 2") and is a pure function of the
// current name set. The seed is returned verbatim when free and is NEVER re-parsed:
// a bare trailing integer cannot be told from a user's own name, so "Kit 808" would
// become "Kit 2" under a stripping rule. Terminates by pigeonhole (one of the first
// N+1 candidates is free for N banks), so it needs no cap. A blank seed comes back
// blank — what a missing name should become is the caller's policy, not the model's.
std::string uniqueDisplayName(const std::string& seed) const;
// Deletes a named bank and its member entries (files untouched — a shell/prune
// concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
// ordinals compact after; if the deleted bank was active, falls back to the pool.
+198
View File
@@ -0,0 +1,198 @@
# src/core/package — the pure RSBK bank-package codec
## Scope
The hand-rolled `RSBK` bank-package container, entirely pure (REAPER-free,
unit-tested outside the DAW): the format contract and version ladder, the JSON
manifest, and the framing/layout codec. No filesystem — the shell
(`src/shell/package`) streams bytes against the layouts produced here. The
export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
both have landed.
## Invariants
- **The container is the proprietary `RSBK` — ruled, not revisitable here.** No
ZIP, no compressor, no link edge to `vendor/WDL/WDL/zlib/`. The version
ladder, not a format swap, is how the format moves.
- **Two version integers, two jobs.** `formatVersion` = what the writer
emitted; `minReaderVersion` = the oldest reader that can read it safely. The
reader's whole rule is `minReaderVersion <= kPackageFormatVersion`. Additive
changes (a new optional manifest key, a new enum value with a defined
degrade) bump `formatVersion` only; structural changes bump both. The full
ladder lives as a comment in `package_format.h` and is READ and validated,
never merely written.
- **TooNew refuses whole.** A `minReaderVersion` above this build yields the
header (so the refusal can name the writer's semver and version) and nothing
else — no manifest, no layout, no half-success. The fields through the writer
semver are FROZEN for all future versions to keep that refusal producible.
- **Every name and path in the format is validated on encode AND decode, to
the extent stated below**, because a package can arrive from anywhere.
Three rules, all in `package_format`, whose doc comments are the itemized
authority:
- `isValidEntryName` — a payload's name is a bare file name (no separators,
no `..` component, no drive/UNC/rooted form, no control bytes, no
Windows-reserved character, no trailing dot/space, no DOS device name,
well-formed UTF-8 only). Path expression is impossible in this field.
- `sameEntryName` — two entry names differing only by ASCII case are ONE
name. Windows and macOS's default APFS would extract them onto a single
file, and a bank authored on a case-sensitive filesystem produces the pair
honestly.
- `isValidNestedSamplePath` — the nested `Sample::relativePath` IS a path by
design, and is the one field here that can express one. It refuses a `..`
component and every absolute form; `BankModel::add` checks only the latter,
so traversal would otherwise reach a future `import_plan` inside a record
the format vouched for. **Scope is traversal and absolute-form only** — no
UTF-8 well-formedness check (unlike `isValidEntryName`), no device-name
check, no case-fold dedup on `relativePath` (unlike `sameEntryName` on the
entry name). Correct for what this field is — a *record* field, not a
filesystem destination; `BankModel::add` owns the rest. Forward contract
for `import_plan`: **the destination file is derived from the entry name,
never from `relativePath`.**
- **Framing only, never a payload.** `bank_package` produces header bytes and
an ordered `{name, offset, length}` layout; it never holds, copies, or hashes
an entry's audio. `decodePackage` proves prefix + payload lengths equal the
observed file size exactly, so truncation and trailing garbage are Malformed
without any payload being read.
- **Per-sample shape has one owner.** Each entry nests a one-sample `BankModel`
blob emitted/parsed by `bank_model`'s own codec (the `bank_book_json`
precedent), so a future `Sample` field reaches packages with no change here.
- **Hostile input: error signaled, never UB** — the `bank_model.h` deserialize
standard, plus allocation caps on every length field so a forged header
cannot demand gigabytes.
## Modules
- `package_format` — the contract: magic, `kPackageFormatVersion` /
`kPackageMinReaderVersion`, the ladder comment, the three-way
`classifyPackageVersion` (`Readable` / `TooNew` / `Malformed`), the three
naming rules above, and `PackageHeader`.
- `package_manifest` — the manifest model (`PackageEntry` / `PackageManifest`)
and its JSON codec. Per entry: bare name, byte length, and a whole-file
`capture::hashBytes` digest (deliberately NOT `hashWavContent`, which skips
chunks and cannot answer "did these bytes survive") — the digest is carried
here, computed where payloads are streamed (shell). The bank's `slot_map`
rides along. Unknown keys skip at every level; duplicate entry names are
rejected both ways.
- `export_plan` — the pure export decision over value inputs (the bank's members
plus the shell's per-file probe result): the verdict (`Ready` / `Incomplete` /
`Refused`), the transport name per shipping entry, and what is excluded and why
(missing / unreadable / an index record the format cannot represent). Owns the
name repair the codec's refusal backstops, and normalizes each shipping record's
`relativePath` to the bare package name — see the transport-name gotcha below.
- `import_plan` — the pure import decision, and the reason the whole feature is
testable without a DAW: the destination bank's display name after
`BankBook`'s own fold, the reminted sample ids and remapped parents, and the
per-entry land / collapse / rename disposition. Also `importLedgerRefusal` (the
import's ledger gate, delegating entirely to `tracking::ledgerDegraded`) and
`ledgerRefusalMessage` (the gate's console-block body, a pure
`(LedgerRefusal, namespace) -> string` fold the shell only supplies the
channel-correct namespace to).
- `bank_package` — framing and arithmetic composing the four above:
`encodePackage` (prefix bytes + layout + total size, stamping this build's
ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed
file size in; header/manifest/layout out), and `requiredPrefixSize` (the
incremental-read seam for the shell). Framing rides `core/wire/bytes.h`.
`package_compat_tests` is declared here with no library of its own: it decodes the
frozen `.rsbank` corpus at `tests/fixtures/package_compat/`, whose README owns the
append-only rule and the per-fixture inventory.
## Gotchas
- Enums nested inside the `BankModel` blob follow `bank_model`'s own rule — an
out-of-range `sourceMode`/`tier` REJECTS the parse — so growing one of those
vocabularies is a `minReaderVersion` bump for packages, not an additive
change. Any enum integer the manifest itself ever adds must instead follow
the degrade-to-`Unknown` rule (`core/wire`'s `BakeStatus` precedent) to stay
additive. The manifest carries no enum of its own today.
- Sample-id rules (remap, collision, dedup across the destination) are
deliberately NOT enforced by the codec — they are `import_plan` decisions. The
codec rejects only what makes the container itself incoherent (duplicate
entry names, invalid names, a non-single-sample nested index).
- **`import_plan` consults no other bank's hashes, and that is the ruling, not
an omission.** An import always creates a NEW bank, so "already present in the
destination bank by content" is exactly "already landed by this same plan".
Cross-bank dedup is not enforced anywhere (`core/model/CLAUDE.md`), so a hash
the pool already holds still lands its own file here.
- `requiredPrefixSize` trusts fields beyond the frozen region only when the
version pair classifies `Readable`; for `TooNew` it stops at the semver —
don't "fix" it to read the manifest length there, a future structural format
may have moved it.
- A package whose header classifies `Readable` (fv > ours, minReader still
within reach — the additive case) but whose manifest fails to parse is
reported `TooNew`, not `Malformed`: the header is valid and already carries
the writer's semver, so the refusal can still name what to install. This
widens `TooNew` to cover "read and failed" as well as "stopped at the frozen
region" — both refuse whole and write nothing, so the safety property is
unchanged, only the message. `classifyPackageVersion` and the frozen-region
`TooNew` path are unaffected; this is the post-manifest-parse branch only.
- **The parse branch is the ONLY one that relabels**, deliberately: a newer
package that trips the manifest cap, a short manifest read, the layout
overflow, or the exact-size proof still reports `Malformed` even with
`formatVersion` above ours. The size proof clearly should — "install 1.9.0"
does not fix a truncated download — and the other three are indistinguishable
from ordinary corruption at the point they fail. Don't "complete" the relabel
across them for symmetry; the split is the answer, not an omission.
- The format carries no algorithm tag for `byteHash` — it is FNV-1a
(`capture::hashBytes`) implicitly. Changing the digest algorithm is a
`minReaderVersion` bump, not additive: an old reader would otherwise compare
a stored digest against bytes hashed the new way and silently misjudge
corruption.
- **A written package carries no path in ANY field.** `isValidNestedSamplePath`
permits a relative `relativePath` because a *record* may hold one, but
`export_plan` writes each shipping entry's `relativePath` as its bare, sanitized
and disambiguated transport name (`export_plan.cpp`'s `e.fileName`), so an
emitted manifest has no separator anywhere and the entry name is the single
naming authority on both sides. The directory component it drops carries no
information — the bank subfolder is a fixed `capture_paths` constant
(`capture_paths.cpp`'s `deriveBankPaths`) the importer re-spells. **The
basename spelling is dropped too, not just the directory**: `e.fileName` is
`uniqueEntryName(sanitizeEntryName(...))`, not the source basename, so a
macOS-authored `Hit?.wav` survives only in `displayName` — the transport name
itself may differ. Accepted for the same reason the directory drop is: the
transport name exists to be a valid, collision-free package entry, not a
faithful copy of the source spelling, and `displayName` is the field that
carries the original for display. The nested-path rule stays as the decode-side
backstop for a package this build did not write.
- **Obligation on the export track: sanitize, don't relay the refusal.**
`serializeManifest` returns one indistinguishable `nullopt` for every rejection
— an unrepresentable name, a case-folded collision, a traversing nested path, a
zero-length entry, a record `BankModel::add` refuses — and most of the naming
rules are Windows'. A bank ingested on macOS/Linux legitimately holds
`Hit?.wav`, `snare .wav`, or two names differing only by case, and a nested
`relativePath` is only checked for the absolute forms where it is written.
Relaying the `nullopt` makes ONE such file an unactionable total failure of the
whole export. `export_plan` must map bank entries to package
names that satisfy these rules (and disambiguate case-folded collisions) before
calling this layer; the codec's refusal is the backstop, not the user-facing
behaviour.
- **NFC/NFD normalization collisions are accepted, not solved.** macOS compares
file names normalization-insensitively, so the NFC and NFD spellings of one
accented name are two manifest entries that extract onto one file — the same
collision class as the ASCII case fold, which `sameEntryName` does catch. A
table-free fix does not exist, and restricting names to ASCII would be
genuinely over-strict for non-English users. Left open knowingly.
- **`duplicateName` folds through a hash set, not a pairwise scan.** Under the
`kMaxManifestBytes` cap (64 MB) a minimal entry is ~100 bytes, so a hostile
package can declare ~670k entries; the former double loop was ~2×10¹¹ pair
comparisons — a multi-minute hang on the decode path an import drives. The
set is keyed on `entryNameKey`, which is `sameEntryName`'s ASCII-case fold
made explicit, so the equivalence rule still has one home (`lowerAscii`).
Do not reintroduce the pairwise scan.
- **Cross-module contract with `src/shell/package`:** a genuinely zero-length
entry cannot round-trip through the filesystem seam there (`appendPayload`
refuses an empty payload — an empty buffer signals an upstream read failure,
not a real entry). `serializeManifest` refuses a zero-length `PackageEntry`
at encode so this layer never produces one; decode does not enforce it (a
hostile/older package declaring one is not this track's concern).
- **`import_plan`'s `spelledLikeABankFile` mints a fresh name even with NO
collision, and that third condition is a deliberate decision, not spec-derived.**
`docs/product/bank-package.md` §"Identity and collision on import" (collision rule 2) ties the auto-rename mint to a *collision*
only; `spelledLikeABankFile` additionally mints whenever the package's own name
isn't spelled the way `deriveBankPaths` spells one (extension, sanitized stem).
Kept for two reasons: uniform folder spelling for every landed file regardless of
origin, and — the sharper one — a hostile entry name that isn't a legal Windows
filename or carries an unexpected extension (e.g. `evil.exe`) lands sanitized
(`evil_<tag>.wav`) rather than verbatim. `ImportPlan` counts this separately from a
genuine folder-name collision (`sanitizeRenameCount` vs `collisionRenameCount`) so
the summary line means what `bank-package.md` §"Identity and collision on import" (collision rule 2) says it means.
+32
View File
@@ -0,0 +1,32 @@
reasampler_pure_library(package_format SOURCES package_format.cpp)
reasampler_test(package_format LINK package_format)
reasampler_pure_library(package_manifest
SOURCES package_manifest.cpp
LINK PUBLIC bank_model slot_map PRIVATE package_format json)
reasampler_test(package_manifest LINK package_manifest)
reasampler_pure_library(export_plan
SOURCES export_plan.cpp
LINK PUBLIC package_manifest PRIVATE package_format)
reasampler_test(export_plan LINK export_plan package_format)
# bytes.h is header-only (see src/core/wire/CLAUDE.md) no wire link edge needed.
reasampler_pure_library(bank_package
SOURCES bank_package.cpp
LINK PUBLIC package_format package_manifest PRIVATE app_version)
# app_version: the tests pin the stamped writer semver against stampVersion().
reasampler_test(bank_package LINK bank_package app_version)
# The frozen compatibility corpus, decoded rather than regenerated. Fixture path: see
# tests/package_fixtures.h.
reasampler_test(package_compat LINK bank_package)
target_compile_definitions(package_compat_tests PRIVATE
REASAMPLER_PACKAGE_FIXTURE_DIR="${REASAMPLER_PACKAGE_FIXTURE_DIR}")
reasampler_pure_library(import_plan
SOURCES import_plan.cpp
LINK PUBLIC package_manifest bank_book origin_ledger PRIVATE package_format capture_paths)
# tracking_authority: the ledger-gate test proves the import does NOT share prune's
# composite blocker, which needs the composite to compare against.
reasampler_test(import_plan LINK import_plan tracking_authority)
+145
View File
@@ -0,0 +1,145 @@
#include "core/package/bank_package.h"
#include <cstring>
#include "core/version/app_version.h"
#include "core/wire/bytes.h"
// Byte offsets (format 1, see package_format.h's ladder): magic at 0, u32
// formatVersion at 4, u32 minReaderVersion at 8, u32 semver length W at 12,
// semver at 16, u32 manifest length M at 16+W, manifest at 20+W, payloads at
// 20+W+M. The region through the semver is the FROZEN refusal surface.
namespace reasampler::package {
namespace {
constexpr std::size_t kMagicBytes = 4;
constexpr std::size_t kSemverLenAt = 12;
constexpr std::size_t kSemverAt = 16;
bool magicMatches(const std::vector<std::uint8_t>& bytes) {
return bytes.size() >= kMagicBytes &&
std::memcmp(bytes.data(), kPackageMagic, kMagicBytes) == 0;
}
std::uint32_t u32At(const std::vector<std::uint8_t>& bytes, std::size_t at) {
std::uint32_t v = 0;
for (std::size_t b = 0; b < 4; ++b)
v |= static_cast<std::uint32_t>(bytes[at + b]) << (b * 8);
return v;
}
// Appends the payload spans for `entries` starting at `firstOffset`. False on
// u64 overflow (a forged length field summing past 2^64 must not wrap into a
// plausible layout).
bool appendSpans(const std::vector<PackageEntry>& entries, std::uint64_t firstOffset,
std::vector<PackageEntrySpan>& out, std::uint64_t& end) {
std::uint64_t offset = firstOffset;
for (const auto& e : entries) {
out.push_back({e.fileName, offset, e.byteLength});
if (offset + e.byteLength < offset) return false;
offset += e.byteLength;
}
end = offset;
return true;
}
} // namespace
std::optional<EncodedPackage> encodePackage(const PackageManifest& m) {
auto manifestJson = serializeManifest(m);
if (!manifestJson) return std::nullopt;
if (manifestJson->size() > kMaxManifestBytes) return std::nullopt;
const std::string& writer = version::stampVersion();
if (writer.size() > kMaxWriterVersionBytes) return std::nullopt;
EncodedPackage enc;
auto& out = enc.prefix;
out.insert(out.end(), kPackageMagic, kPackageMagic + kMagicBytes);
wire::putLE(out, kPackageFormatVersion);
wire::putLE(out, kPackageMinReaderVersion);
wire::putLE(out, static_cast<std::uint32_t>(writer.size()));
out.insert(out.end(), writer.begin(), writer.end());
wire::putLE(out, static_cast<std::uint32_t>(manifestJson->size()));
out.insert(out.end(), manifestJson->begin(), manifestJson->end());
if (!appendSpans(m.entries, out.size(), enc.layout, enc.totalSize))
return std::nullopt;
return enc;
}
DecodedPackage decodePackage(const std::vector<std::uint8_t>& prefix,
std::uint64_t totalFileSize) {
DecodedPackage dec; // status starts Malformed; every early return means it
wire::ByteReader r(prefix);
if (r.str(kMagicBytes) != std::string(kPackageMagic, kMagicBytes)) return dec;
const std::uint32_t formatVersion = r.u32();
const std::uint32_t minReader = r.u32();
if (!r.ok) return dec;
const PackageReadability verdict = classifyPackageVersion(formatVersion, minReader);
if (verdict == PackageReadability::Malformed) return dec;
const std::uint32_t semverLen = r.u32();
if (!r.ok || semverLen > kMaxWriterVersionBytes) return dec;
std::string writer = r.str(semverLen);
if (!r.ok) return dec;
dec.header = PackageHeader{formatVersion, minReader, std::move(writer)};
if (verdict == PackageReadability::TooNew) {
// Refuse whole: the header names the writer for the message; nothing
// past the frozen region is read, and no manifest is produced.
dec.status = PackageReadability::TooNew;
return dec;
}
const std::uint32_t manifestLen = r.u32();
if (!r.ok || manifestLen > kMaxManifestBytes) return dec;
const std::string manifestJson = r.str(manifestLen);
if (!r.ok) return dec;
auto manifest = deserializeManifest(manifestJson);
if (!manifest) {
// A newer additive format's parse failure reports TooNew, not the
// unactionable Malformed — the header (with the writer semver) is
// already valid here. See this directory's CLAUDE.md for the tradeoff.
if (formatVersion > kPackageFormatVersion) dec.status = PackageReadability::TooNew;
return dec;
}
std::uint64_t end = 0;
std::vector<PackageEntrySpan> layout;
if (!appendSpans(manifest->entries, r.pos, layout, end)) return dec;
// Exact-size proof: a byte missing (truncation) or a byte extra (trailing
// garbage) both fail, even though no payload is read here.
if (end != totalFileSize) return dec;
dec.status = PackageReadability::Readable;
dec.manifest = std::move(*manifest);
dec.layout = std::move(layout);
dec.prefixSize = r.pos;
return dec;
}
std::optional<std::uint64_t> requiredPrefixSize(const std::vector<std::uint8_t>& bytes) {
if (bytes.size() < kSemverAt) return kSemverAt;
if (!magicMatches(bytes)) return std::nullopt;
const auto verdict = classifyPackageVersion(u32At(bytes, 4), u32At(bytes, 8));
if (verdict == PackageReadability::Malformed) return std::nullopt;
const std::uint32_t semverLen = u32At(bytes, kSemverLenAt);
if (semverLen > kMaxWriterVersionBytes) return std::nullopt;
const std::uint64_t throughSemver = kSemverAt + semverLen;
if (verdict == PackageReadability::TooNew) return throughSemver;
if (bytes.size() < throughSemver + 4) return throughSemver + 4;
const std::uint32_t manifestLen = u32At(bytes, static_cast<std::size_t>(throughSemver));
if (manifestLen > kMaxManifestBytes) return std::nullopt;
return throughSemver + 4 + manifestLen;
}
} // namespace reasampler::package
+72
View File
@@ -0,0 +1,72 @@
#pragma once
// bank_package — RSBK framing and layout arithmetic: header encode, prefix
// decode, and the ordered {name, offset, length} entry layout. See this
// directory's CLAUDE.md for the framing-only invariant. Pure: no filesystem.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/package/package_format.h"
#include "core/package/package_manifest.h"
namespace reasampler::package {
// Where one payload sits in the finished package file (absolute byte offset).
struct PackageEntrySpan {
std::string name;
std::uint64_t offset = 0;
std::uint64_t length = 0;
bool operator==(const PackageEntrySpan& o) const {
return name == o.name && offset == o.offset && length == o.length;
}
};
// The write side's product: the prefix bytes (magic through manifest, written
// verbatim as the file's head), the layout to stream each payload at, and the
// finished file's exact size — what the shell verifies after the last append.
struct EncodedPackage {
std::vector<std::uint8_t> prefix;
std::vector<PackageEntrySpan> layout;
std::uint64_t totalSize = 0;
};
// Encodes the package prefix for `m`, stamping this build's version pair and
// version::stampVersion() as the writer semver. nullopt when the manifest
// cannot be represented (serializeManifest's rejections) — refused on encode so
// an undecodable package is never written.
std::optional<EncodedPackage> encodePackage(const PackageManifest& m);
// The read side's product. header is meaningful for Readable and TooNew (a
// refusal must still name the writer), and on any Malformed reached after the
// header parsed (a corrupt manifest at this build's own version carries the
// real pair, not the 0/0 unparsed default); manifest, layout, and prefixSize
// only for Readable — TooNew produces NO manifest, so a refused decode cannot
// half-succeed.
struct DecodedPackage {
PackageReadability status = PackageReadability::Malformed;
PackageHeader header;
PackageManifest manifest;
std::vector<PackageEntrySpan> layout;
std::uint64_t prefixSize = 0;
};
// Decodes a package's leading bytes. `totalFileSize` is the on-disk size the
// caller observed: decode proves prefix + payload lengths equal it exactly, so
// a truncated or garbage-extended file is Malformed even though the payloads
// themselves are never read here. `prefix` may be the whole file or any head of
// it that requiredPrefixSize accepted. Error signaled, never UB.
DecodedPackage decodePackage(const std::vector<std::uint8_t>& prefix,
std::uint64_t totalFileSize);
// How many leading bytes decodePackage needs. May grow as bytes arrive: with
// fewer than the returned count on hand, read to that count and ask again.
// For a TooNew package it stops at the frozen region (through the writer
// semver) — field positions beyond it belong to the newer format and are not
// trusted. nullopt: these bytes can never frame a package (bad magic,
// incoherent versions, an over-cap length field) — stop reading.
std::optional<std::uint64_t> requiredPrefixSize(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::package
+177
View File
@@ -0,0 +1,177 @@
// export_plan.cpp — see export_plan.h for the contract.
#include "core/package/export_plan.h"
#include <cstddef>
#include "core/package/package_format.h"
namespace reasampler::package {
namespace {
// The bare file name a bank-relative path ends in. Separators are matched in both
// spellings: a persisted index may hold either on Windows.
std::string baseNameOf(const std::string& path) {
const std::size_t sep = path.find_last_of("/\\");
return sep == std::string::npos ? path : path.substr(sep + 1);
}
// Mirrors package_format.cpp's trailing-dot/space rule so a truncated stem never
// reintroduces the collision isValidEntryName exists to prevent.
std::string stripTrailingDotsAndSpaces(std::string s) {
while (!s.empty() && (s.back() == '.' || s.back() == ' ')) s.pop_back();
return s;
}
// Truncates to at most `max` bytes without splitting a UTF-8 sequence (the rule
// itself is package_format.cpp's isWellFormedUtf8). A sequence landing exactly on
// the cut is dropped whole, one character short of `max`, rather than checked for
// cleanliness — over-truncating by one character is cheap insurance against a
// subtly wrong boundary check.
std::string truncateUtf8(std::string s, std::size_t max) {
if (s.size() <= max) return s;
s.resize(max);
while (!s.empty() && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
if (!s.empty() && static_cast<unsigned char>(s.back()) >= 0xC0) s.pop_back();
return s;
}
// `name` with `suffix` inserted before its extension, trimmed so the result still
// fits the entry-name cap. `suffix.size() + ext.size()` can exceed the cap on its
// own (a long extension, a two-digit disambiguation suffix) — clamped rather than
// subtracted unchecked, which would underflow the size_t `room` below and turn
// truncateUtf8 into a silent no-op. An extension that alone leaves no room even
// after the whole stem is dropped is dropped too; uniqueEntryName's own floor
// covers what even that cannot fix.
std::string insertSuffix(const std::string& name, const std::string& suffix) {
const std::size_t dot = name.find_last_of('.');
const bool hasExt = dot != std::string::npos && dot > 0;
std::string stem = hasExt ? name.substr(0, dot) : name;
std::string ext = hasExt ? name.substr(dot) : std::string();
if (suffix.size() >= kMaxEntryNameBytes) return std::string();
if (ext.size() > kMaxEntryNameBytes - suffix.size()) ext.clear();
const std::size_t room = kMaxEntryNameBytes - suffix.size() - ext.size();
stem = truncateUtf8(std::move(stem), room);
return stem + suffix + ext;
}
bool nameTaken(const std::string& candidate, const std::vector<std::string>& taken) {
for (const std::string& t : taken)
if (sameEntryName(candidate, t)) return true;
return false;
}
// A transport name distinct from every name already claimed, under the format's own
// case-folding equivalence (package_format.h's sameEntryName).
std::string uniqueEntryName(const std::string& base, const std::vector<std::string>& taken) {
if (!nameTaken(base, taken)) return base;
// Each iteration either returns a name both valid and distinct from `taken`, or
// advances to the next suffix; taken.size() + 2 attempts is enough by pigeonhole
// now that insertSuffix cannot underflow. The floor below is the residual case
// validity alone can still fail — an extension so long insertSuffix must drop it
// on every attempt tried here.
for (std::size_t n = 2; n <= taken.size() + 2; ++n) {
const std::string candidate = insertSuffix(base, "_" + std::to_string(n));
if (!nameTaken(candidate, taken) && isValidEntryName(candidate)) return candidate;
}
// Floors like sanitizeEntryName's own "entry" floor: always valid, regardless of
// how base's own extension behaved.
return sanitizeEntryName("entry_" + std::to_string(taken.size() + 2));
}
// What BankModel::add and the manifest's nested-path rule together accept — the pair
// package_manifest::serializeManifest checks per entry. The codec's refusal is the
// backstop; classifying here is what lets the export name the offending entry.
bool recordRepresentable(const model::Sample& s) {
return !s.id.empty() && isValidNestedSamplePath(s.relativePath);
}
ExcludedEntry excludedFrom(const model::Sample& s, ExclusionReason reason) {
ExcludedEntry e;
e.sampleId = s.id;
e.displayName = s.displayName;
e.relativePath = s.relativePath;
e.reason = reason;
return e;
}
} // namespace
std::string sanitizeEntryName(const std::string& rawFileName) {
std::string n = rawFileName;
for (char& c : n) {
const unsigned char u = static_cast<unsigned char>(c);
if (u < 0x20 || u == 0x7F || u == '/' || u == '\\' || u == ':' || u == '*' ||
u == '?' || u == '|' || u == '<' || u == '>' || u == '"')
c = '_';
}
// One byte of headroom so the prefix repair below still fits the cap.
n = stripTrailingDotsAndSpaces(truncateUtf8(std::move(n), kMaxEntryNameBytes - 1));
if (isValidEntryName(n)) return n;
// One prefix answers every remaining reserved form at once: "." / "..", a DOS
// device name, and a name the strips emptied.
std::string prefixed = stripTrailingDotsAndSpaces("_" + n);
if (isValidEntryName(prefixed)) return prefixed;
// Ill-formed UTF-8 is what is left, and isValidEntryName is the only authority on
// it here, so fold the whole non-ASCII range rather than re-deriving the scanner.
for (char& c : prefixed)
if (static_cast<unsigned char>(c) >= 0x80) c = '_';
prefixed = stripTrailingDotsAndSpaces(prefixed);
return isValidEntryName(prefixed) ? prefixed : std::string("entry");
}
ExportPlan planExport(const ExportInputs& in) {
ExportPlan plan;
plan.manifest.bankDisplayName = in.bankDisplayName;
bool anyAbsent = false;
bool anyUnrepresentable = false;
std::vector<std::string> takenNames;
std::vector<std::string> shippedIds;
for (const ExportCandidate& c : in.candidates) {
if (!recordRepresentable(c.sample)) {
plan.excluded.push_back(
excludedFrom(c.sample, ExclusionReason::RecordUnrepresentable));
anyUnrepresentable = true;
continue;
}
if (c.fileState != SourceFileState::Present) {
plan.excluded.push_back(excludedFrom(
c.sample, c.fileState == SourceFileState::Unreadable
? ExclusionReason::FileUnreadable
: ExclusionReason::FileMissing));
anyAbsent = true;
continue;
}
PackageEntry e;
e.sample = c.sample;
e.fileName = uniqueEntryName(sanitizeEntryName(baseNameOf(c.sample.relativePath)),
takenNames);
// The transport record names its payload by the package name and nothing
// else, so the manifest carries no path at all — the bank subfolder is a
// fixed constant the importer re-spells through capture_paths.
e.sample.relativePath = e.fileName;
takenNames.push_back(e.fileName);
shippedIds.push_back(c.sample.id);
plan.sourceRelativePaths.push_back(c.sample.relativePath);
plan.manifest.entries.push_back(std::move(e));
}
// Display positions follow membership: an excluded entry's slot marker would name
// a sample the package does not carry.
plan.manifest.slots = in.slots;
plan.manifest.slots.reconcile(shippedIds);
plan.verdict = anyUnrepresentable ? ExportVerdict::Refused
: anyAbsent ? ExportVerdict::Incomplete
: ExportVerdict::Ready;
return plan;
}
} // namespace reasampler::package
+87
View File
@@ -0,0 +1,87 @@
#pragma once
// export_plan — the pure export decision: which bank entries ship, what each one is
// named inside the package, what is absent, and therefore whether the export may
// proceed at all. Values in, verdict out — the shell probes the filesystem and hands
// the results here. Pure: no filesystem, no host types.
#include <string>
#include <vector>
#include "core/model/bank_model.h"
#include "core/model/slot_map.h"
#include "core/package/package_manifest.h"
namespace reasampler::package {
// What the shell's filesystem probe found for one indexed entry. Missing and
// Unreadable stay distinct all the way to the refusal message: the file is gone vs.
// the file is there and will not open, which have opposite recoveries.
enum class SourceFileState {
Present,
Missing,
Unreadable,
};
struct ExportCandidate {
model::Sample sample;
SourceFileState fileState = SourceFileState::Missing;
};
// One bank as the planner sees it: the display name that rides in the manifest
// envelope, the members in bank insertion order, and the bank's display positions.
struct ExportInputs {
std::string bankDisplayName;
std::vector<ExportCandidate> candidates;
model::SlotMap slots;
};
// Why an indexed entry cannot ship.
enum class ExclusionReason {
FileMissing,
FileUnreadable,
// The index record itself cannot be written: an empty id, or a relativePath the
// format's nested-path rule refuses. Not something a confirm can proceed past.
RecordUnrepresentable,
};
struct ExcludedEntry {
std::string sampleId;
std::string displayName;
std::string relativePath;
ExclusionReason reason = ExclusionReason::FileMissing;
};
enum class ExportVerdict {
Ready, // every candidate ships
Incomplete, // a file is absent or unreadable; the rest may ship behind an explicit confirm
Refused, // an index record the format cannot represent — no confirm path
};
struct ExportPlan {
ExportVerdict verdict = ExportVerdict::Ready;
// Entries in bank order, each carrying its transport name and its record. The
// shell measures `byteLength`/`byteHash` from the payload, so they are 0/"" here;
// `exportTimestamp` is the shell's clock read and is 0 here too.
PackageManifest manifest;
// Where each shipping entry's bytes are read from, parallel to
// `manifest.entries` — the record's own relativePath is normalized to the bare
// package name (see the transport-name note in this directory's CLAUDE.md), so
// the source spelling has to survive separately.
std::vector<std::string> sourceRelativePaths;
std::vector<ExcludedEntry> excluded;
};
ExportPlan planExport(const ExportInputs& in);
// The smallest repair of one bare file name that satisfies isValidEntryName —
// separators, reserved characters and control bytes to '_', an over-long name
// truncated on a UTF-8 boundary, and an underscore prefix for the reserved forms
// ("." / ".." / a DOS device name). Never returns a name isValidEntryName refuses.
// Why sanitize rather than relay the codec's refusal: this directory's own
// CLAUDE.md, "Obligation on the export track."
std::string sanitizeEntryName(const std::string& rawFileName);
} // namespace reasampler::package
+221
View File
@@ -0,0 +1,221 @@
#include "core/package/import_plan.h"
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include "core/capture/capture_paths.h"
#include "core/package/package_format.h"
#include "core/util/ascii_ws.h"
namespace reasampler::package {
namespace {
using capture::bankRelativeForName;
using capture::deriveBankPaths;
using capture::sanitizeStem;
using util::isAsciiWs;
// Shares BankBook::nameKey's whitespace set (core/util/ascii_ws.h) so a name nameKey
// would fold to empty is never treated as recorded here.
bool blankName(const std::string& s) {
for (char c : s)
if (!isAsciiWs(c)) return false;
return true;
}
// "kick.wav" -> "kick"; a name with no dot is its own stem. deriveBankPaths re-adds
// the extension, so handing it the full name would file "kick.wav" as "kick.wav.wav".
std::string stemOf(const std::string& fileName) {
const std::size_t dot = fileName.rfind('.');
if (dot == std::string::npos || dot == 0) return fileName;
return fileName.substr(0, dot);
}
// True when `fileName` is already spelled the way this tool spells a bank file, so a
// package landing in a fresh project keeps the names it travelled with. Anything else
// is minted through deriveBankPaths, which is also the sanitizer.
bool spelledLikeABankFile(const std::string& fileName) {
const std::string stem = stemOf(fileName);
return stem != fileName && sanitizeStem(stem) == stem && fileName == stem + ".wav";
}
// The bank-folder names an import must not land on: what is there already, plus what
// this import has minted so far. Case-folded, because the two filesystems this tool
// ships on would treat "Kick.wav" and "kick.wav" as one file.
//
// `bankFolderFileNames` comes from `listFolderFileNames` (shell/package/package_io),
// which skips non-regular files — so a DIRECTORY sharing a bank file's name is
// invisible here. The plan then never mints around it, and the later exclusive-create
// land fails on that one entry (WriteFailed, rolled back). Safe direction ("never
// overwrite" still holds) but worth knowing before chasing a WriteFailed report that
// traces back to a same-named folder in the bank directory; test_import_landing's
// rollback suite deliberately exploits this to exercise the rollback path.
class NameSet {
public:
explicit NameSet(const std::vector<std::string>& present) {
keys_.reserve(present.size());
for (const std::string& n : present) keys_.insert(entryNameKey(n));
}
bool taken(const std::string& name) const { return keys_.count(entryNameKey(name)) != 0; }
void claim(const std::string& name) { keys_.insert(entryNameKey(name)); }
private:
std::unordered_set<std::string> keys_;
};
// The name this entry lands under. Terminates: each attempt carries a distinct
// counter, and the taken set is finite.
std::string mintFileName(const std::string& projectDir, const std::string& packageName,
const std::string& uniqueTag, const NameSet& taken) {
if (spelledLikeABankFile(packageName) && !taken.taken(packageName)) return packageName;
const std::string stem = stemOf(packageName);
std::string tag = uniqueTag;
for (int n = 2;; ++n) {
const std::string candidate = deriveBankPaths(projectDir, stem, tag).fileName;
if (!taken.taken(candidate)) return candidate;
tag = uniqueTag + "-" + std::to_string(n);
}
}
} // namespace
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status) {
// Delegates the refuse/proceed decision entirely to ledgerDegraded() rather than
// re-deriving it from the two named statuses, so a future degraded status added
// there is refused here too rather than silently falling through to None.
if (!tracking::ledgerDegraded(status)) return LedgerRefusal::None;
// Below this point status is known degraded; only the message variant is picked.
// Unreadable gets its own "corrupt, may be cleared" wording; every other degraded
// status (today only FutureVersion) gets the "written by a newer build" wording.
return status == tracking::LedgerStatus::Unreadable ? LedgerRefusal::Malformed
: LedgerRefusal::FutureVersion;
}
// Mirrors prune's abort block in structure and tone (shell/actions/prune_action.cpp),
// because a user who has hit that one should recognise this one. Every recovery line
// names THIS build's namespace: a beta user handed the stable spelling clears the wrong
// key and is still blocked.
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace) {
if (refusal == LedgerRefusal::None) return {};
std::string msg =
"ReaSampler import: ABORTED -- the file-tracking ledger could not be read. "
"Nothing was imported.\n";
if (refusal == LedgerRefusal::Malformed) {
msg += "The stored file-tracking ledger is malformed. It has been left intact "
"rather than overwritten, so it can be repaired or cleared:\n"
" reaper.SetProjExtState(0, \"" + extStateNamespace + "\", \"owned_files\", \"\")\n"
"Clearing it makes every existing bank file un-reclaimable (they stop "
"being attributable to ReaSampler); no file is lost. Reopen the project "
"afterwards -- the block is held for the rest of this session.\n";
} else {
msg += "The stored file-tracking ledger was written by a NEWER version of "
"ReaSampler than this one, so its records cannot be read safely. It has "
"been left intact and will NOT be overwritten. Reopen the project with "
"that newer version -- do NOT clear this key from here, that would "
"discard tracking records this build cannot see. The block is held for "
"the rest of this session.\n";
}
msg += "An import can land hundreds of files in one gesture. With no readable "
"ledger, none of them could be given a birth record, and every one would be "
"permanently unreclaimable.\n";
return msg;
}
std::string bankFolderDir(const std::string& projectDir) {
// Only the directory half of the result is wanted; the stem is a placeholder.
return deriveBankPaths(projectDir, "bank", std::string{}).absoluteDir;
}
ImportPlan planImport(const PackageManifest& manifest,
const BankBook& destination,
const std::string& projectDir,
const std::vector<std::string>& bankFolderFileNames,
const std::string& uniqueTag) {
ImportPlan plan;
plan.seedBankName = blankName(manifest.bankDisplayName)
? std::string(kDefaultImportBankName)
: manifest.bankDisplayName;
plan.bankDisplayName = destination.uniqueDisplayName(plan.seedBankName);
plan.bankNameAdjusted = plan.bankDisplayName != plan.seedBankName;
NameSet taken(bankFolderFileNames);
// The destination bank is created empty by this same import, so "already in the
// destination bank by content" is exactly "already landed by this plan" — the
// hash set below IS that bank's findByHash. Cross-bank dedup is deliberately not
// enforced (core/model/CLAUDE.md), so other banks' hashes are not consulted.
std::unordered_map<std::string, std::string> landedIdForHash;
// Every package id, including a collapsed one's, so a parent link that pointed at
// a duplicate still resolves to the entry that survived it.
std::unordered_map<std::string, std::string> idRemap;
plan.entries.reserve(manifest.entries.size());
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
const PackageEntry& src = manifest.entries[i];
PlannedEntry e;
e.manifestIndex = i;
const std::string& hash = src.sample.contentHash;
if (!hash.empty()) {
const auto hit = landedIdForHash.find(hash);
if (hit != landedIdForHash.end()) {
e.action = EntryAction::Collapse;
idRemap[src.sample.id] = hit->second;
++plan.collapseCount;
plan.entries.push_back(std::move(e));
continue;
}
}
e.destFileName = mintFileName(projectDir, src.fileName, uniqueTag, taken);
e.renamed = e.destFileName != src.fileName;
taken.claim(e.destFileName);
e.sample = src.sample;
e.sample.id = std::string(kImportIdPrefix) + uniqueTag + "-" + e.destFileName;
e.sample.relativePath = bankRelativeForName(e.destFileName);
if (!hash.empty()) landedIdForHash.emplace(hash, e.sample.id);
idRemap[src.sample.id] = e.sample.id;
++plan.landCount;
// A rename happens for one of two reasons: the package's own name was already
// taken (spelledLikeABankFile true but the mint's fast path lost the race to
// `taken`), or the name never qualified for that fast path at all (sanitize).
if (e.renamed) {
if (spelledLikeABankFile(src.fileName)) ++plan.collisionRenameCount;
else ++plan.sanitizeRenameCount;
}
plan.entries.push_back(std::move(e));
}
// Second pass: the remap must be complete before a parent is resolved, since a
// sample may precede its own parent in manifest order.
for (PlannedEntry& e : plan.entries) {
if (e.action != EntryAction::Land || !e.sample.provenance) continue;
const auto hit = idRemap.find(e.sample.provenance->parentSampleId);
e.sample.provenance->parentSampleId =
hit == idRemap.end() ? std::string{} : hit->second;
}
// The package's display order, over the ids that actually landed. SlotMap's own
// repair rules settle the rest: two package ids collapsed onto one landed id give
// one slot (first wins), and a landed sample the package never positioned is
// appended by BankBook::reconcileSlots afterwards.
std::vector<std::pair<std::string, int>> slotPairs;
for (const std::string& oldId : manifest.slots.orderedIds()) {
const auto hit = idRemap.find(oldId);
if (hit == idRemap.end()) continue;
slotPairs.emplace_back(hit->second, manifest.slots.slotOf(oldId));
}
plan.slots = model::SlotMap::fromEntries(slotPairs);
return plan;
}
} // namespace reasampler::package
+88
View File
@@ -0,0 +1,88 @@
#pragma once
// import_plan — the pure import decision: the destination bank's display name after
// the book's own uniqueness fold, the reminted sample ids and remapped parents, and
// the per-entry land / collapse / rename disposition. Value inputs only; no
// filesystem, no session handle, no host types.
#include <cstddef>
#include <string>
#include <vector>
#include "core/model/bank_book.h"
#include "core/model/slot_map.h"
#include "core/package/package_manifest.h"
#include "core/tracking/origin_ledger.h"
namespace reasampler::package {
// The bank name a package that recorded none (or a blank one) imports under.
inline constexpr const char* kDefaultImportBankName = "Imported bank";
// The prefix every imported sample id is reminted under, so a package's own ids —
// unique only within the project that made them — never enter this index.
inline constexpr const char* kImportIdPrefix = "pkg-";
// Which of the two refusal messages the import owes the user, if either.
//
// Keyed on the LEDGER STATUS ALONE, never on prune's composite blockedByTracking:
// that flag also fires on undecodable rsusage_* keys, which govern which files a
// DELETION may touch. An import deletes nothing and computes no protected set — it
// writes birth records — so an unreadable usage key must not refuse one.
enum class LedgerRefusal { None, Malformed, FutureVersion };
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status);
// The console-block body for a refusal — a pure (LedgerRefusal, namespace) -> string
// fold, so the wording is assertable without a DAW. `extStateNamespace` is the
// channel-correct namespace (`version::extStateNamespace()`) every recovery line must
// name, so a beta user is never handed the stable spelling. Empty string for None —
// callers only reach this once `importLedgerRefusal` has already returned a refusal.
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace);
// What one manifest entry does when the import runs.
// - Land: write the payload under destFileName and add `sample`.
// - Collapse: an equal contentHash already lands in this same import, so the payload
// is NOT written and no entry is added. Writing it and letting
// BankModel::add collapse the entry would leave the file referenced by
// nothing — an orphan manufactured by a dedup.
enum class EntryAction { Land, Collapse };
struct PlannedEntry {
std::size_t manifestIndex = 0;
EntryAction action = EntryAction::Land;
std::string destFileName; // Land only — a bare name in the bank folder
model::Sample sample; // Land only — id, path and parent already remapped
bool renamed = false; // the package's own name was taken, so a fresh one was minted
};
struct ImportPlan {
std::string bankDisplayName;
bool bankNameAdjusted = false; // the seed was taken, so the name carries a suffix
std::string seedBankName; // the seed the probe started from
std::vector<PlannedEntry> entries; // one per manifest entry, in manifest order
model::SlotMap slots; // the package's slots over the reminted ids
int landCount = 0;
int collapseCount = 0;
// Two distinct triggers, counted separately (bank-package.md:447 defines the first
// as THE collision counter; conflating the second into it would misreport a mint
// that never collided as a collision).
int collisionRenameCount = 0; // the package's own name was already taken in the bank folder
int sanitizeRenameCount = 0; // the package's name was not spelled the way this tool spells
// a bank file (see spelledLikeABankFile, core/package/CLAUDE.md)
};
// The bank folder an import lands into — the same expression capture uses, so an
// imported file is spelled exactly like a captured one.
std::string bankFolderDir(const std::string& projectDir);
// Decides everything about an import except the bytes. `bankFolderFileNames` are the
// bare names already present in that folder (never overwritten); `uniqueTag` is the
// shell's per-import disambiguator, extended with an ascending counter where one tag
// is not enough. Total: every manifest entry yields exactly one PlannedEntry.
ImportPlan planImport(const PackageManifest& manifest,
const BankBook& destination,
const std::string& projectDir,
const std::vector<std::string>& bankFolderFileNames,
const std::string& uniqueTag);
} // namespace reasampler::package
+126
View File
@@ -0,0 +1,126 @@
#include "core/package/package_format.h"
#include <cstddef>
#include "core/util/relative_path.h"
namespace reasampler::package {
PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
std::uint32_t minReaderVersion) {
if (formatVersion == 0 || minReaderVersion == 0) return PackageReadability::Malformed;
if (minReaderVersion > formatVersion) return PackageReadability::Malformed;
if (minReaderVersion > kPackageFormatVersion) return PackageReadability::TooNew;
return PackageReadability::Readable;
}
namespace {
// Hand-rolled rather than std::tolower: that fold is locale-dependent, so two
// machines reading the same package could disagree on which names collide.
char lowerAscii(unsigned char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : static_cast<char>(c);
}
// Windows device names claim the whole entry regardless of extension
// (CON, CON.wav, con.WAV are all the same reserved device) — checked against
// the portion before the first dot only. The trailing three pairs are the UTF-8
// spellings of COM¹/COM²/COM³/LPT¹/LPT²/LPT³: Windows reads those ISO 8859-1
// superscripts as digits in a device name. COM0/LPT0 are NOT reserved.
bool isDosDeviceName(const std::string& name) {
std::string base = name.substr(0, name.find('.'));
for (char& c : base) c = lowerAscii(static_cast<unsigned char>(c));
static const std::string kReserved[] = {
"con", "prn", "aux", "nul",
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
"com\xC2\xB9", "com\xC2\xB2", "com\xC2\xB3",
"lpt\xC2\xB9", "lpt\xC2\xB2", "lpt\xC2\xB3",
};
for (const auto& r : kReserved) if (base == r) return true;
return false;
}
// Table-free UTF-8 well-formedness. Overlong encodings, surrogate halves and
// code points past U+10FFFF are rejected as hard as a structural length error:
// the UTF-8 -> UTF-16 conversion a host must perform maps an ill-formed
// sequence to U+FFFD unless it opts into failing, so two names differing only
// in invalid bytes would otherwise collapse onto one destination file.
bool isWellFormedUtf8(const std::string& s) {
const auto* p = reinterpret_cast<const unsigned char*>(s.data());
const std::size_t n = s.size();
for (std::size_t i = 0; i < n;) {
const unsigned char c = p[i];
std::size_t extra = 0;
std::uint32_t cp = 0;
if (c < 0x80) { ++i; continue; }
else if ((c & 0xE0) == 0xC0) { extra = 1; cp = c & 0x1Fu; }
else if ((c & 0xF0) == 0xE0) { extra = 2; cp = c & 0x0Fu; }
else if ((c & 0xF8) == 0xF0) { extra = 3; cp = c & 0x07u; }
else return false; // a stray continuation byte, or a 5/6-byte lead
if (i + extra >= n) return false;
for (std::size_t k = 1; k <= extra; ++k) {
const unsigned char cont = p[i + k];
if ((cont & 0xC0) != 0x80) return false;
cp = (cp << 6) | (cont & 0x3Fu);
}
if (extra == 1 && cp < 0x80) return false;
if (extra == 2 && cp < 0x800) return false;
if (extra == 3 && cp < 0x10000) return false;
if (cp > 0x10FFFF) return false;
if (cp >= 0xD800 && cp <= 0xDFFF) return false;
i += extra + 1;
}
return true;
}
} // namespace
bool isValidEntryName(const std::string& name) {
if (name.empty() || name.size() > kMaxEntryNameBytes) return false;
if (name == "." || name == "..") return false;
// fopen/CreateFileW both silently strip a trailing dot or space at
// creation, so "a.wav " and "a.wav" would collide on one file.
if (name.back() == '.' || name.back() == ' ') return false;
for (unsigned char c : name) {
// NUL and other control bytes truncate at the first filesystem call
// (std::ofstream, fopen, CreateFileW off .c_str()) — two names that
// differ only after the NUL land on the same file.
if (c < 0x20 || c == 0x7F) return false;
if (c == '/' || c == '\\' || c == ':') return false;
if (c == '*' || c == '?' || c == '|' || c == '<' || c == '>' || c == '"') return false;
}
if (isDosDeviceName(name)) return false;
return isWellFormedUtf8(name);
}
bool sameEntryName(const std::string& a, const std::string& b) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i)
if (lowerAscii(static_cast<unsigned char>(a[i])) !=
lowerAscii(static_cast<unsigned char>(b[i])))
return false;
return true;
}
std::string entryNameKey(const std::string& name) {
std::string key;
key.reserve(name.size());
for (unsigned char c : name) key += lowerAscii(c);
return key;
}
bool isValidNestedSamplePath(const std::string& path) {
if (util::isAbsolutePath(path)) return false;
// Component-wise, not a substring scan: "take..final/a.wav" is a legal
// relative path, "bank/../evil.wav" is not.
for (std::size_t start = 0;; ) {
const std::size_t sep = path.find_first_of("/\\", start);
const std::size_t end = (sep == std::string::npos) ? path.size() : sep;
if (path.compare(start, end - start, "..") == 0) return false;
if (sep == std::string::npos) return true;
start = sep + 1;
}
}
} // namespace reasampler::package
+110
View File
@@ -0,0 +1,110 @@
#pragma once
// package_format — the RSBK bank-package contract: magic, the version ladder,
// the readability classification, and the three naming rules below. Pure:
// standard library only. The framing codec that acts on this contract is
// bank_package; the manifest grammar is package_manifest.
#include <cstdint>
#include <string>
namespace reasampler::package {
// Version ladder for the RSBK container (read-and-validate, like the origin
// ledger's "v"):
//
// format 1 (current) magic "RSBK" | u32 formatVersion | u32 minReaderVersion
// | u32 len + writer semver | u32 len + JSON manifest
// | payloads concatenated in manifest entry order.
// All integers little-endian.
//
// Two integers, two jobs: formatVersion is what the writer emitted (monotonic,
// bumped on ANY change); minReaderVersion is the oldest reader that can read the
// package safely (bumped only on a STRUCTURAL change — a field's meaning shifts,
// a section is removed, framing moves; an additive change — a new optional
// manifest key, a new enum value with a defined degrade — leaves it alone). The
// reader's whole rule: read iff minReaderVersion <= kPackageFormatVersion.
// formatVersion beyond that is message text and log material only.
//
// FROZEN FOR ALL FUTURE VERSIONS: the fields through the writer semver. A
// too-new package must still yield the writer's version so the refusal can name
// what to install — a structural change may rearrange anything after the semver,
// never before it.
inline constexpr char kPackageMagic[4] = {'R', 'S', 'B', 'K'};
inline constexpr std::uint32_t kPackageFormatVersion = 1;
inline constexpr std::uint32_t kPackageMinReaderVersion = 1;
// Hostile-input allocation caps (error signaled, never a multi-gigabyte
// allocation off a forged length field). Generous against real content: a
// semver is ~10 bytes; a manifest for hundreds of samples is well under 1 MB.
inline constexpr std::uint32_t kMaxWriterVersionBytes = 64;
inline constexpr std::uint32_t kMaxManifestBytes = 64u * 1024u * 1024u;
inline constexpr std::size_t kMaxEntryNameBytes = 255;
// The three-way read verdict (the FutureVersion precedent): TooNew refuses the
// whole package before anything is produced; Malformed is a header no honest
// writer emits. Also the status of a full prefix decode in bank_package.
enum class PackageReadability {
Readable,
TooNew,
Malformed,
};
// Classify a stored header pair against THIS build's ladder. minReaderVersion
// above kPackageFormatVersion is TooNew; a zero version or minReader >
// formatVersion is Malformed (a writer cannot require a reader newer than what
// it wrote).
PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
std::uint32_t minReaderVersion);
// The entry-name rule: a bare file name only. Rejects empty, ".", the exact
// ".." component (a name can only ever be one component, since separators are
// banned below — a substring scan would over-reject legal names like
// "take..final.wav"), any control byte (NUL included — truncates at the first
// filesystem call and collides two distinct manifest entries onto one file) or
// 0x7F, any '/', '\\' or ':' (which also bans every absolute form — drive, UNC,
// rooted), any Windows-reserved character (`*?|<>"`), a trailing dot or space
// (silently stripped at file creation, so "a.wav " and "a.wav" would collide),
// a DOS device name (CON/PRN/AUX/NUL/COM1-9/LPT1-9 plus the superscript
// COM/LPT 1-3 forms, case-insensitive, with or without an extension), names
// over kMaxEntryNameBytes, and any byte sequence that is not well-formed UTF-8.
bool isValidEntryName(const std::string& name);
// The format's name-equivalence rule: two entry names that differ only by ASCII
// case are ONE name. Windows and macOS's default APFS are case-insensitive, so
// "Kick.wav" and "kick.wav" would extract onto a single file — and a bank
// authored on a case-sensitive filesystem produces that pair honestly. Non-ASCII
// bytes compare exactly (see this directory's CLAUDE.md on NFC/NFD).
bool sameEntryName(const std::string& a, const std::string& b);
// sameEntryName's fold made explicit: the ASCII-lower-cased bytes, so
// entryNameKey(a) == entryNameKey(b) exactly when sameEntryName(a, b). For a caller
// holding many names at once — folding them into a set is what turns an O(n^2)
// pairwise scan into a linear one.
std::string entryNameKey(const std::string& name);
// The one field in the format that CAN express a path: a nested Sample's
// relativePath, which is bank-relative by design. Rejects every absolute form
// (the shared util::isAbsolutePath test) and any ".." component — BankModel::add
// checks only the former, so traversal reaches the format without this.
bool isValidNestedSamplePath(const std::string& path);
// The fixed header, informational semver included. writerVersion is
// version::stampVersion() on the write side — it exists so a TooNew refusal can
// tell the user which build to install; it never gates. Defaults are 0/0, NOT
// the current ladder pair, so a header that never parsed reads as obviously
// unset rather than as a plausible 1/1. The fields are meaningful whenever they
// are non-zero, not only on success: a decode that got past the header and
// failed later (a corrupt manifest) reports the real pair alongside Malformed.
struct PackageHeader {
std::uint32_t formatVersion = 0;
std::uint32_t minReaderVersion = 0;
std::string writerVersion;
bool operator==(const PackageHeader& o) const {
return formatVersion == o.formatVersion &&
minReaderVersion == o.minReaderVersion &&
writerVersion == o.writerVersion;
}
};
} // namespace reasampler::package
+230
View File
@@ -0,0 +1,230 @@
#include "core/package/package_manifest.h"
#include <unordered_set>
#include <utility>
#include "core/json/json.h"
#include "core/package/package_format.h"
namespace reasampler::package {
namespace {
using json::numToStr;
using ObjWriter = json::Writer;
// Shared by serializeManifest and deserializeManifest — see this directory's
// CLAUDE.md for why duplicate names are rejected both ways. Equivalence is the
// format's, not std::string's: entryNameKey is sameEntryName's ASCII-case fold.
//
// A set, not the pairwise scan this replaced: under kMaxManifestBytes a hostile
// package can declare hundreds of thousands of minimal entries, and O(n^2) over that
// is a multi-minute hang on the decode path an import drives.
bool duplicateName(const std::vector<PackageEntry>& entries) {
std::unordered_set<std::string> seen;
seen.reserve(entries.size());
for (const auto& e : entries)
if (!seen.insert(entryNameKey(e.fileName)).second) return true;
return false;
}
// The one-sample BankModel image of `s` — bank_model's own writer, verbatim, so
// the per-sample shape has exactly one owner. nullopt when add() would reject
// the record (its guards are the format's guards too).
std::optional<std::string> nestSample(const model::Sample& s) {
model::BankModel one;
if (one.add(s) != model::AddResult::Added) return std::nullopt;
return one.serialize();
}
} // namespace
bool PackageEntry::operator==(const PackageEntry& o) const {
return fileName == o.fileName && byteLength == o.byteLength &&
byteHash == o.byteHash && sample == o.sample;
}
bool PackageManifest::operator==(const PackageManifest& o) const {
return bankDisplayName == o.bankDisplayName && exportTimestamp == o.exportTimestamp &&
entries == o.entries && slots == o.slots;
}
std::optional<std::string> serializeManifest(const PackageManifest& m) {
for (const auto& e : m.entries) {
if (!isValidEntryName(e.fileName)) return std::nullopt;
if (!isValidNestedSamplePath(e.sample.relativePath)) return std::nullopt;
// Cross-module contract with src/shell/package — see this directory's
// CLAUDE.md.
if (e.byteLength == 0) return std::nullopt;
}
if (duplicateName(m.entries)) return std::nullopt;
std::string out;
{
ObjWriter root(out);
root.keyStr("bankName", m.bankDisplayName);
root.keyRaw("exported", numToStr(m.exportTimestamp));
root.keyBegin("entries");
out += '[';
for (std::size_t i = 0; i < m.entries.size(); ++i) {
const auto& e = m.entries[i];
auto nested = nestSample(e.sample);
if (!nested) return std::nullopt;
if (i) out += ',';
ObjWriter w(out);
w.keyStr("name", e.fileName);
// byteLength rides as a signed decimal; 2^63 bytes is beyond any file.
w.keyRaw("length", numToStr(static_cast<std::int64_t>(e.byteLength)));
w.keyStr("hash", e.byteHash);
w.keyRaw("index", *nested);
}
out += ']';
root.keyBegin("slots");
out += m.slots.serialize();
} // root closes here (NRVO note in json::Writer)
return out;
}
namespace {
// Mirrors bank_book_json's private slots parser: [{id, slot}, ...] pairs handed
// to SlotMap::fromEntries, which owns the defensive repair rules. Deliberately
// does NOT reject a repeated "id"/"slot" key the way the root and entry parsers
// below reject theirs — this grammar belongs to core/model's bank_book_json, and
// diverging here would give one wire shape two behaviours in two files. The
// stakes differ too: a repeated "name" decides which file an entry lands on,
// while a repeated "id" here still feeds SlotMap::fromEntries's deterministic
// first-wins/never-double-occupy repair, so no ambiguity survives. Do not
// "finish" the repeat-key rejection here to match the parsers below.
bool parseSlots(json::Reader& r, model::SlotMap& out) {
std::vector<std::pair<std::string, int>> pairs;
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) {
out = model::SlotMap::fromEntries(pairs);
return true;
}
do {
if (!r.consume('{')) return false;
std::string id;
int slot = 0;
bool haveId = false, haveSlot = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
else { if (!r.skipValue()) return false; }
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || !haveSlot) return false;
pairs.emplace_back(std::move(id), slot);
} while (r.consume(','));
if (!r.consume(']')) return false;
out = model::SlotMap::fromEntries(pairs);
return true;
}
bool parseEntry(json::Reader& r, PackageEntry& e) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // an entry needs all four fields
bool haveName = false, haveLength = false, haveHash = false, haveSample = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
// A repeated key is rejected here exactly as at the root — same format
// question, one level down.
if (key == "name") {
if (haveName || !r.parseString(e.fileName)) return false;
haveName = true;
} else if (key == "length") {
std::int64_t v = 0;
if (haveLength || !r.parseInt64(v)) return false;
if (v < 0) return false;
e.byteLength = static_cast<std::uint64_t>(v);
haveLength = true;
} else if (key == "hash") {
if (haveHash || !r.parseString(e.byteHash)) return false;
haveHash = true;
} else if (key == "index") {
if (haveSample) return false;
std::string raw;
if (!r.captureValue(raw)) return false;
auto idx = model::BankModel::deserialize(raw);
// Exactly one sample: add()'s silent drop (rejected record) or a
// multi-sample blob both fail the entry rather than half-parse.
if (!idx || idx->size() != 1) return false;
e.sample = idx->all().front();
haveSample = true;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveName || !haveLength || !haveHash || !haveSample) return false;
return isValidEntryName(e.fileName) && isValidNestedSamplePath(e.sample.relativePath);
}
bool parseManifest(json::Reader& r, PackageManifest& m) {
if (!r.consume('{')) return false;
r.skipWs();
// "Which duplicate keys are legal" is a format contract, so it is answered
// for every root key rather than only for the one that would accumulate:
// a repeated key is rejected, never last-wins. Unknown keys may repeat —
// they are skipped, and a future format must stay free to add them.
bool haveBankName = false, haveExported = false, haveEntries = false, haveSlots = false;
const auto firstTime = [](bool& seen) { const bool ok = !seen; seen = true; return ok; };
if (!r.consume('}')) { // not the empty-object shortcut: parse the members
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "bankName") {
if (!firstTime(haveBankName)) return false;
if (!r.parseString(m.bankDisplayName)) return false;
} else if (key == "exported") {
if (!firstTime(haveExported)) return false;
if (!r.parseInt64(m.exportTimestamp)) return false;
} else if (key == "entries") {
if (!firstTime(haveEntries)) return false;
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
PackageEntry e;
if (!parseEntry(r, e)) return false;
m.entries.push_back(std::move(e));
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else if (key == "slots") {
if (!firstTime(haveSlots)) return false;
if (!parseSlots(r, m.slots)) return false;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
}
r.skipWs();
if (!r.eof()) return false; // trailing garbage — even after an empty object
return !duplicateName(m.entries);
}
} // namespace
std::optional<PackageManifest> deserializeManifest(const std::string& json) {
PackageManifest m;
json::Reader r(json);
if (!parseManifest(r, m)) return std::nullopt;
return m;
}
} // namespace reasampler::package
+60
View File
@@ -0,0 +1,60 @@
#pragma once
// package_manifest — the RSBK manifest model and its JSON codec. Per-sample
// shape is NOT owned here: each entry nests a one-sample BankModel blob emitted
// by bank_model's own writer (the bank_book_json precedent), so a future Sample
// field reaches packages for free. Pure: no filesystem, no host types.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/model/bank_model.h"
#include "core/model/slot_map.h"
namespace reasampler::package {
// One payload's transport record. `byteHash` is capture::hashBytes over the
// payload's raw bytes — the whole-file digest, deliberately NOT hashWavContent
// (which skips chunks and so cannot answer "did these bytes survive the trip").
// FNV-1a: a corruption detector, not a cryptographic checksum. The codec only
// carries the digest; hashing happens where the payload is streamed (shell).
struct PackageEntry {
std::string fileName; // bare name inside the package (isValidEntryName)
std::uint64_t byteLength = 0;
std::string byteHash;
model::Sample sample;
bool operator==(const PackageEntry& o) const;
};
// Everything the manifest carries besides the payloads: informational envelope
// (source bank name, export moment), the entries, and the bank's display
// positions (a bank's arrangement is part of what the user built).
struct PackageManifest {
std::string bankDisplayName;
std::int64_t exportTimestamp = 0; // unix epoch seconds
std::vector<PackageEntry> entries;
model::SlotMap slots;
bool operator==(const PackageManifest& o) const;
};
// Emits the manifest JSON. nullopt when the manifest cannot be represented: an
// invalid or duplicate entry name (duplicate by sameEntryName, not string
// equality), a nested relativePath isValidNestedSamplePath refuses, a
// zero-length entry (see this directory's CLAUDE.md — the shell's payload-append
// seam cannot round-trip one), or a sample record BankModel itself would reject
// (empty id, absolute path) — refusing on encode so an undecodable package is
// never written.
std::optional<std::string> serializeManifest(const PackageManifest& m);
// Parses manifest JSON (nullopt on malformed input, never UB). Unknown keys are
// skipped at every level, so an additive newer manifest still parses; a repeated
// KNOWN root key is rejected rather than last-wins. Rejects what encode rejects
// except the zero-length entry — names and nested paths are validated on BOTH
// directions because a package can arrive from anywhere — plus a missing
// per-entry field or a negative length.
std::optional<PackageManifest> deserializeManifest(const std::string& json);
} // namespace reasampler::package
+1
View File
@@ -38,6 +38,7 @@ OriginKind kindFromInt(int v) {
case 2: return OriginKind::Ingest;
case 3: return OriginKind::Recapture;
case 4: return OriginKind::Resample;
case 5: return OriginKind::PackageImport;
default: return OriginKind::Unknown;
}
}
+6 -5
View File
@@ -15,11 +15,12 @@ namespace reasampler::tracking {
// lifted from a legacy path-only manifest, or one whose creator did not know.
// PERSISTED AS INTEGERS: never renumber an existing value, only append.
enum class OriginKind {
Unknown = 0,
Capture = 1,
Ingest = 2,
Recapture = 3, // regenerated in place from its recorded source recipe
Resample = 4, // baked from an instrument's own processing chain
Unknown = 0,
Capture = 1,
Ingest = 2,
Recapture = 3, // regenerated in place from its recorded source recipe
Resample = 4, // baked from an instrument's own processing chain
PackageImport = 5, // package-sourced vs Ingest's user-picked; unrecoverable once merged
};
// One system-created file's birth record. `relativePath` is the key and is ALWAYS
+3 -2
View File
@@ -3,8 +3,8 @@
## Scope
Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte
loading, unit-interval clamping, the absolute-path rejection test, and the
per-segment envelope curve law.
loading, unit-interval clamping, the absolute-path rejection test, the
per-segment envelope curve law, and the ASCII-whitespace fold test.
## Modules
@@ -19,6 +19,7 @@ per-segment envelope curve law.
before curves existed play unchanged, and what the knob law's centre detent exists to keep
reachable from the dial.
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
- `ascii_ws` (`core/util`, header-only) — the ONE ASCII-whitespace test (space/tab/CR/LF) behind `BankBook::nameKey`'s trim, shared by `core/package/import_plan`'s blank-bank-name fallback. Same rationale as `relative_path`: two independently-maintained copies could drift on what counts as blank.
## Gotchas
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// ascii_ws — the ONE ASCII-whitespace test shared by every fold that must agree with
// BankBook::nameKey's trim (space/tab/CR/LF): a drifted second copy could accept a
// package bank name nameKey would treat as blank, or vice versa.
namespace reasampler::util {
inline bool isAsciiWs(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
} // namespace reasampler::util
+14 -3
View File
@@ -42,7 +42,9 @@ settled 2026-07-23):
reapply touches solo not at all.
- **GUID-keyed, reorder-safe.** Membership keys on track GUID (`GetTrackGUID`),
never track index; tolerates unknown/stale GUIDs (pruned on reconcile via
`ViewModeModel::reconcile(liveGuids)`).
`ViewModeModel::reconcile(liveGuids)`). The same rule binds one level down: a
parked track's per-FX offline state keys on the FX's own identity
(`TrackFX_GetFXGUID`), never its slot — see `fx_offline`.
- **Relative/portable state only** in the persisted view section (GUID strings,
mode ids — no absolute paths, no index positions).
- **Show-both semantics.** A per-track "pin visible across modes" flag that
@@ -64,8 +66,11 @@ settled 2026-07-23):
- **Mechanism: fixed item lanes.** Map mode → lane; toggle drives per-lane
play/show so only the active mode's lane is present. Items keep their real
position and real track — nothing is moved in time or deleted.
- **Membership: adoption rule for new items; active mode for new tracks.** New
tracks are tagged to the active mode at creation. New items follow an
- **Membership: adoption rule for new items; active mode for new tracks absent an
explicit tag.** New tracks are tagged to the active mode at creation **only when the GUID carries no
membership record** — an explicit tag wins over the detector, because the detector
classifies content the *user* made, not content the tool made and already
classified. New items follow an
adoption rule: if the item's track has pre-existing managed-eligible content
spanning exactly one mode, the item adopts that mode; the active-mode
fallback applies only when the track is empty or already spans multiple
@@ -92,6 +97,7 @@ settled 2026-07-23):
## Modules
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, the per-mode `SoloCache` it owns, JSON round-trip.
- `fx_offline` — the per-FX offline snapshot's KEY and its restore resolution: `FxKeying` (Identity / Slot), `FxOfflineState`, the planned `FxOfflineOp`, and `resolveFxRestore`, which matches each captured state to the FX it came from against the chain as it stands at restore time. An identity that is no longer live is DROPPED and counted (`FxRestoreDrops`, reported through `describeFxRestoreDrops`), never re-pointed at a slot — see the `FxKeying` and `resolveFxRestore` comments in `fx_offline.h` for why. Slot keying survives only for snapshots lifted from a pre-identity `view_state` and for park plans, where every live slot is the target by construction.
- `solo_cache` — the per-mode solo surface: `SoloCache` (mode id → GUID → raw `I_SOLO`), the soloed-subset filter, and `planSoloRestore`, whose two drop rules (dead GUID, not visible in the incoming mode) and their reasoning live in its header.
- `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell.
- `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch.
@@ -107,6 +113,11 @@ settled 2026-07-23):
- `kManagedLanePrefix` ("reasampler:") is stable-forever like an action-id
string — changing it strands the ownership of every already-minted lane in
every already-saved project.
- The `view_state` blob's version ladder lives beside `kViewStateVersion` in
`view_mode_model.cpp`. v2 writes the v1 slot array BESIDE the identity array so
a downgrade keeps the behavior it had; the version field is written but
deliberately not validated on read, because an unreadable `view_state` falls
back to a default model and loses every membership tag.
- `guid_diff::GuidBaseline` must have `reset()` called on every detected
project switch, or the next `observe()` will diff across two unrelated
projects and mass-tag (or miss) content.
+8 -4
View File
@@ -4,12 +4,16 @@ reasampler_test(lane_keys LINK lane_keys)
reasampler_pure_library(solo_cache SOURCES solo_cache.cpp)
reasampler_test(solo_cache LINK solo_cache)
# lane_keys and solo_cache are PUBLIC: the lane-minting plan names managed lanes through the
# one durable-key convention, and ViewModeModel exposes the SoloCache by reference, so every
# consumer has to resolve those symbols too.
reasampler_pure_library(fx_offline SOURCES fx_offline.cpp)
reasampler_test(fx_offline LINK fx_offline)
# lane_keys, solo_cache and fx_offline are PUBLIC: the lane-minting plan names managed lanes
# through the one durable-key convention, ViewModeModel exposes the SoloCache by reference, and
# TrackSnapshot/TrackPlan carry the FX-offline types by value so every consumer has to
# resolve those symbols too.
reasampler_pure_library(view_mode_model
SOURCES view_mode_model.cpp
LINK PRIVATE json PUBLIC lane_keys solo_cache)
LINK PRIVATE json PUBLIC lane_keys solo_cache fx_offline)
reasampler_test(view_mode_model LINK view_mode_model)
reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model)
+92
View File
@@ -0,0 +1,92 @@
#include "core/view/fx_offline.h"
#include <cstddef>
#include <map>
namespace reasampler {
namespace {
// Identity -> current slot. First occurrence wins: REAPER mints one GUID per FX
// instance, so a repeat can only come from a corrupt/hand-edited chain, and
// picking one deterministically beats writing twice.
std::map<std::string, int> slotByIdentity(const std::vector<std::string>& liveFxGuids) {
std::map<std::string, int> byGuid;
for (std::size_t i = 0; i < liveFxGuids.size(); ++i) {
if (liveFxGuids[i].empty()) continue; // unidentifiable FX is not a restore target
byGuid.emplace(liveFxGuids[i], static_cast<int>(i));
}
return byGuid;
}
} // namespace
FxRestoreResolution resolveFxRestore(const std::vector<FxOfflineOp>& planned,
const std::vector<std::string>& liveFxGuids) {
FxRestoreResolution res;
const std::map<std::string, int> byGuid = slotByIdentity(liveFxGuids);
const int liveCount = static_cast<int>(liveFxGuids.size());
for (const FxOfflineOp& op : planned) {
if (op.keying == FxKeying::Identity) {
if (op.fxGuid.empty()) {
// REAPER reported no GUID at capture time — distinct from a real
// captured identity going missing: the FX may still be live, we
// just never had a name for it. Counted separately so the report
// never claims it was deleted (see describeFxRestoreDrops).
++res.drops.unidentified;
continue;
}
auto it = byGuid.find(op.fxGuid);
if (it == byGuid.end()) {
++res.drops.missingIdentity;
continue;
}
res.writes.push_back(FxOfflineWrite{it->second, op.offline});
} else {
if (op.slot < 0 || op.slot >= liveCount) {
++res.drops.slotOutOfRange;
continue;
}
res.writes.push_back(FxOfflineWrite{op.slot, op.offline});
}
}
return res;
}
std::string describeFxRestoreDrops(const FxRestoreDrops& drops, int trackCount) {
if (drops.total() <= 0) return {};
std::string msg = "ReaSampler: Design View restore dropped " +
std::to_string(drops.total()) + " captured FX offline state(s) on " +
std::to_string(trackCount) + " track(s) -- ";
std::vector<std::string> clauses;
if (drops.missingIdentity > 0) {
clauses.push_back(std::to_string(drops.missingIdentity) +
" FX no longer in the chain (deleted or replaced while parked)");
}
if (drops.unidentified > 0) {
clauses.push_back(std::to_string(drops.unidentified) +
" FX REAPER could not identify at capture time (no GUID reported), "
"so it could not be matched now");
}
if (drops.slotOutOfRange > 0) {
clauses.push_back(std::to_string(drops.slotOutOfRange) +
" from a project saved before FX identity was recorded, whose slot no longer exists");
}
for (std::size_t i = 0; i < clauses.size(); ++i) {
if (i) msg += ", ";
msg += clauses[i];
}
// Every dropped entry is still sitting exactly where park left it — offline
// — because the restore that would have flipped it back never ran. Say
// that, not the reassuring-but-wrong "left as it was" (park itself was the
// change; restore is what didn't happen for these).
msg += ". Each was left offline, as park left it, with no snapshot left to "
"restore it -- switch it back on by hand.\n";
return msg;
}
} // namespace reasampler
+93
View File
@@ -0,0 +1,93 @@
#pragma once
// The per-FX offline snapshot's key and its restore resolution: what a captured
// FX state is keyed BY, and how that key resolves against the chain as it stands
// at restore time. Pure — the FX identity is an opaque string the shell reads
// from REAPER (TrackFX_GetFXGUID) and hands in.
#include <string>
#include <vector>
namespace reasampler {
// How a set of per-FX entries is keyed. `Slot` is the position-addressed shape:
// a park plan (every live slot, by construction) or a snapshot lifted from a
// project saved before identity was recorded. A live snapshot is always
// `Identity` — a chain reordered while the track is parked makes a slot a lie.
enum class FxKeying { Identity, Slot };
// One FX's captured offline state. Under Identity keying `fxGuid` is that FX's
// own durable identity; under Slot keying it is empty and the entry's POSITION
// in the snapshot is the slot it was captured from.
struct FxOfflineState {
std::string fxGuid;
int offline = 0; // int, not bool — mirrors TrackSnapshot's defensive contract
bool operator==(const FxOfflineState& o) const {
return fxGuid == o.fxGuid && offline == o.offline;
}
};
// One per-FX offline write as PLANNED. The keying travels with the op; see
// FxKeying above and resolveFxRestore below for why a missing identity is
// never re-pointed at a slot.
struct FxOfflineOp {
std::string guid; // track GUID
FxKeying keying = FxKeying::Identity;
std::string fxGuid; // FX identity, under Identity keying
int slot = 0; // write target under Slot keying only
bool offline = false;
bool operator==(const FxOfflineOp& o) const {
return guid == o.guid && keying == o.keying && fxGuid == o.fxGuid &&
slot == o.slot && offline == o.offline;
}
};
// One resolved write: TrackFX_SetOffline(track, fxIndex, offline).
struct FxOfflineWrite {
int fxIndex = 0;
bool offline = false;
bool operator==(const FxOfflineWrite& o) const {
return fxIndex == o.fxIndex && offline == o.offline;
}
};
// Captured state a restore could not apply. All three counts mean the same
// act: the entry was dropped and no FX was written in its place.
struct FxRestoreDrops {
int missingIdentity = 0; // identity-keyed entry with no live FX carrying that GUID
int unidentified = 0; // identity-keyed entry whose captured fxGuid was itself empty
int slotOutOfRange = 0; // slot-keyed entry whose capture-time slot no longer exists
int total() const { return missingIdentity + unidentified + slotOutOfRange; }
void add(const FxRestoreDrops& o) {
missingIdentity += o.missingIdentity;
unidentified += o.unidentified;
slotOutOfRange += o.slotOutOfRange;
}
bool operator==(const FxRestoreDrops& o) const {
return missingIdentity == o.missingIdentity && unidentified == o.unidentified &&
slotOutOfRange == o.slotOutOfRange;
}
};
struct FxRestoreResolution {
std::vector<FxOfflineWrite> writes; // in planned order
FxRestoreDrops drops;
};
// Resolves each planned op against the live chain, where `liveFxGuids[i]` is the
// identity of the FX at slot `i` right now (empty when REAPER reported none).
// An identity that is not live is DROPPED, never re-pointed at a slot; a live FX
// no op names is left entirely alone.
FxRestoreResolution resolveFxRestore(const std::vector<FxOfflineOp>& planned,
const std::vector<std::string>& liveFxGuids);
// One console line describing a whole apply's drops, or "" when nothing was
// dropped — the degrade is reported rather than swallowed.
std::string describeFxRestoreDrops(const FxRestoreDrops& drops, int trackCount);
} // namespace reasampler
+108 -8
View File
@@ -196,8 +196,13 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
{guid, Flag::MainSend, 0},
{guid, Flag::FxEnable, 0},
};
// Park has no identity question to answer: it offlines every slot that is
// live right now, so slot keying IS the addressing. In production fxCount
// is always 0 here — planToggle calls this with fxCount=0 and the D2 shell
// expands the real writes itself via TrackFX_GetCount (parkFxOffline in
// shell/view/view.cpp); a nonzero fxCount only exercises this loop in tests.
for (int i = 0; i < fxCount; ++i)
p.fxOffline.push_back({guid, i, true});
p.fxOffline.push_back(FxOfflineOp{guid, FxKeying::Slot, {}, i, true});
return p;
}
@@ -209,8 +214,11 @@ TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
{guid, Flag::MainSend, snap.mainSend},
{guid, Flag::FxEnable, snap.fxEnable},
};
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i)
p.fxOffline.push_back({guid, static_cast<int>(i), snap.fxOffline[i] != 0});
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i) {
const FxOfflineState& fx = snap.fxOffline[i];
p.fxOffline.push_back(FxOfflineOp{guid, snap.fxKeying, fx.fxGuid,
static_cast<int>(i), fx.offline != 0});
}
return p;
}
@@ -361,13 +369,33 @@ using json::writeIntArray;
std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;
// Version ladder for the stored blob, under the FOREVER-STABLE "view_state" key.
// Only the per-FX snapshot shape has ever moved:
//
// v1 "snapshots":[{...,"fxOffline":[0,1,0]}] — offline state by SLOT
// v2 "snapshots":[{...,"fxOffline":[0,1,0], — the v1 array, still written
// "fx":[{"guid":"{..}","offline":0},...]}] — by FX IDENTITY
//
// v2 writes BOTH: "fx" is what this build reads, and the v1 array is what a build
// that predates identity keying reads — a downgrade keeps exactly the behavior it
// had rather than losing every captured FX state to an unknown key. Reading, "fx"
// wins outright; a blob carrying only "fxOffline" lifts to a Slot-keyed snapshot
// and restores by slot ONCE, which is the only thing its bytes can support (the
// restore then clears it, so the next park captures identities).
//
// "version" is WRITTEN but deliberately not validated on read: an unreadable
// view_state falls back to a default model, which loses every membership tag, so
// leniency is the safe direction here — the opposite call from origin_ledger,
// where a misread blob would put prune's deletion authority on bad data.
constexpr int kViewStateVersion = 2;
} // namespace
std::string ViewModeModel::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", intToStr(1));
root.keyRaw("version", intToStr(kViewStateVersion));
root.keyStr("activeMode", activeModeId_);
root.keyBegin("modes");
@@ -410,7 +438,8 @@ std::string ViewModeModel::serialize() const {
}
out += ']';
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, fxOffline[] }
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable,
// fxOffline[], fx[] } — see the version ladder above.
root.keyBegin("snapshots");
out += '[';
{
@@ -424,8 +453,28 @@ std::string ViewModeModel::serialize() const {
e.keyRaw("showInMixer", intToStr(snap.showInMixer));
e.keyRaw("mainSend", intToStr(snap.mainSend));
e.keyRaw("fxEnable", intToStr(snap.fxEnable));
std::vector<int> bySlot;
bySlot.reserve(snap.fxOffline.size());
for (const FxOfflineState& fx : snap.fxOffline) bySlot.push_back(fx.offline);
e.keyBegin("fxOffline");
writeIntArray(out, snap.fxOffline);
writeIntArray(out, bySlot);
// A Slot-keyed snapshot has no identities to write — emitting an
// "fx" array for it would invent the very keys it lacks.
if (snap.fxKeying == FxKeying::Identity) {
e.keyBegin("fx");
out += '[';
bool firstFx = true;
for (const FxOfflineState& fx : snap.fxOffline) {
if (!firstFx) out += ',';
firstFx = false;
ObjWriter f(out);
f.keyStr("guid", fx.fxGuid);
f.keyRaw("offline", intToStr(fx.offline));
}
out += ']';
}
}
}
out += ']';
@@ -537,6 +586,32 @@ bool parseMembership(json::Reader& r, MembershipIndex& idx) {
return r.consume(']');
}
// The identity-keyed "fx" array. Both keys are mandatory (strict like parseLanes);
// an EMPTY guid is accepted, because a live capture records one when REAPER
// reported no identity for that FX — the entry is honest about being unresolvable
// rather than being silently dropped at write time.
bool parseFxStates(json::Reader& r, std::vector<FxOfflineState>& out) {
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true;
do {
if (!r.consume('{')) return false;
FxOfflineState fx;
bool haveGuid = false, haveOffline = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "guid") { if (!r.parseString(fx.fxGuid)) return false; haveGuid = true; }
else if (k == "offline") { if (!r.parseInt(fx.offline)) return false; haveOffline = true; }
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveGuid || !haveOffline) return false;
out.push_back(fx);
} while (r.consume(','));
return r.consume(']');
}
bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps) {
if (!r.consume('[')) return false;
r.skipWs();
@@ -546,6 +621,10 @@ bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps
std::string guid;
TrackSnapshot snap;
bool haveGuid = false;
std::vector<int> bySlot;
bool haveSlot = false;
std::vector<FxOfflineState> byIdentity;
bool haveIdentity = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
@@ -554,12 +633,33 @@ bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps
else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; }
else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; }
else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; }
else if (k == "fxOffline") { if (!r.parseIntArray(snap.fxOffline)) return false; }
else if (k == "fxOffline") { if (!r.parseIntArray(bySlot)) return false; haveSlot = true; }
else if (k == "fx") {
if (!parseFxStates(r, byIdentity)) return false;
haveIdentity = true;
}
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveGuid || guid.empty()) return false;
snaps[guid] = snap;
// A blob carrying both arrays at different lengths is not something this
// writer (or any prior version) produces — reject rather than silently
// trusting "fx" over a slot array that disagrees with it; an unreadable
// view_state falls back to a default model per the version-ladder note
// above, which is the same leniency-direction call already made there.
if (haveIdentity && haveSlot && byIdentity.size() != bySlot.size()) return false;
// "fx" wins outright — v2 writes the slot array beside it purely so an
// older build can still read something (see the version ladder above).
if (haveIdentity) {
snap.fxOffline = std::move(byIdentity);
snap.fxKeying = FxKeying::Identity;
} else {
for (int offline : bySlot) snap.fxOffline.push_back(FxOfflineState{{}, offline});
snap.fxKeying = FxKeying::Slot;
}
snaps[guid] = std::move(snap);
} while (r.consume(','));
return r.consume(']');
}
+11 -17
View File
@@ -12,6 +12,7 @@
#include <string>
#include <vector>
#include "core/view/fx_offline.h"
#include "core/view/solo_cache.h"
namespace reasampler {
@@ -201,13 +202,16 @@ struct TrackSnapshot {
int mainSend = 0; // B_MAINSEND prior value
int fxEnable = 0; // I_FXEN prior value
// Prior per-FX offline state, index = fx slot.
std::vector<int> fxOffline;
// Prior per-FX offline state in capture-time slot order, keyed per fxKeying:
// by the FX's own identity (live capture), or by position (a snapshot lifted
// from a project saved before identity was recorded).
std::vector<FxOfflineState> fxOffline;
FxKeying fxKeying = FxKeying::Identity;
bool operator==(const TrackSnapshot& o) const {
return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
mainSend == o.mainSend && fxEnable == o.fxEnable &&
fxOffline == o.fxOffline;
fxOffline == o.fxOffline && fxKeying == o.fxKeying;
}
};
@@ -231,17 +235,6 @@ struct TrackFlagOp {
}
};
// One per-FX offline write: TrackFX_SetOffline(guid, fxIndex, offline).
struct FxOfflineOp {
std::string guid;
int fxIndex = 0;
bool offline = false;
bool operator==(const FxOfflineOp& o) const {
return guid == o.guid && fxIndex == o.fxIndex && offline == o.offline;
}
};
// One managed-lane play/show write the shell must apply (translated into
// C_LANEPLAYS / I_FIXEDLANE / B_FIXEDLANE_HIDDEN). Emitted for MANAGED lanes
// only — never a manual lane; enforced in planToggle and mirrored by
@@ -257,9 +250,10 @@ struct LanePlayOp {
};
// The complete set of operations to park one inactive leaf, or restore one
// leaf. Park uses fixed zeros; restore uses a snapshot's values. fxOffline is
// per known FX slot: on park all slots go offline (from the snapshot's slot
// count); on restore each slot returns to its captured value.
// leaf. Park uses fixed zeros; restore uses a snapshot's values. Park's
// fxOffline ops are slot-keyed (every live slot goes offline); restore's carry
// the snapshot's keying and are resolved against the live chain by
// resolveFxRestore before any write.
struct TrackPlan {
std::vector<TrackFlagOp> flags;
std::vector<FxOfflineOp> fxOffline;
+8 -3
View File
@@ -4,7 +4,8 @@
The bindable action families routed through REAPER's `command_id`/`gaccel`/
`hookcommand` contract (Design View toggle actions, bank actions, the prune
action, and the shared registration plumbing/table), plus the three drag-out
action, the bank-package import action, and the shared registration
plumbing/table), plus the three drag-out
outcome shells (OS hand-off, instrument drop, arrange drop), plus the
extension-side ingest-through-the-bank shell. This is
where user-facing REAPER actions and OS-level drag/drop live; the underlying
@@ -16,8 +17,10 @@ is owned by other directories and only skinned here.
- **Ingest is an extension act; the instrument is a read-only bank consumer.** Any
instrument code path that captures, imports, inserts a timeline item, or writes
back into the bank is a bug — the instrument reads and plays only.
- **`arrange_drop_win` is the only timeline-placing shell in this directory**, and
it places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s
- **`arrange_drop_win` is the only timeline-placing shell IN THIS DIRECTORY** — the
claim scopes here, not to the system: `shell/capture` holds two more
(`RunInsertSelected` and `render_in_place`, the third verb). `arrange_drop_win`
places because the USER dragged a card onto the arrange. Root `CLAUDE.md`'s
capture/placement separation forbids a CAPTURE placing an item; a deliberate drop
is placement on demand. No other module here may grow an `InsertMedia` call.
- **Ingest NEVER inserts a timeline item.** Arrange capture→bank→assign reuses the
@@ -37,9 +40,11 @@ is owned by other directories and only skinned here.
## Modules
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
- `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body.
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
- `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Builds and shows every message the import produces, but the ledger-refusal body itself is `core/package::ledgerRefusalMessage` — a pure fold this TU only supplies the channel-correct namespace to — so the wording is assertable without a DAW. `doImportBankPackage`/`doImportBankPackageFile` return the minted bank id on a landed import (empty otherwise) so a caller can focus it; the verb itself is promptless.
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism.
## Gotchas
+196
View File
@@ -0,0 +1,196 @@
// package_export_action.cpp — see package_export_action.h for the contract this TU
// preserves. main.cpp owns the API pointers; this TU gets them extern.
#include "shell/actions/package_export_action.h"
#include <cstddef>
#include <cstdint>
#include <ctime>
#include <string>
#include <vector>
#include "core/capture/capture_paths.h" // projectDirOfRpp, sanitizeStem
#include "shell/package/export_bank.h"
#include "shell/package/package_pickers.h"
#include "shell/persist/session.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_ShowMessageBox
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
constexpr const char* kUnsavedProjectMsg =
"ReaSampler export: save the project first -- an unsaved project has no bank folder "
"to read from.\n";
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return capture::projectDirOfRpp(std::string(buf.data()));
}
// 6 == YES; anything else cancels (SDK ~6544).
bool confirmed(const std::string& msg, const char* title) {
return ShowMessageBox(msg.c_str(), title, 4) == 6;
}
std::string entryLine(const package::ExcludedEntry& e) {
const char* why = e.reason == package::ExclusionReason::FileMissing ? "missing"
: e.reason == package::ExclusionReason::FileUnreadable ? "unreadable"
: "unusable index record";
return " " + (e.displayName.empty() ? e.sampleId : e.displayName) + " [" + why +
"] " + e.relativePath + "\n";
}
// `maxLines` == 0 lists everything (the console record); a positive cap keeps a
// confirm dialog readable on a bank with hundreds of absent files, prune's own
// truncate-the-confirm-not-the-report discipline.
std::string excludedManifest(const std::vector<package::ExcludedEntry>& excluded,
std::size_t maxLines) {
std::string msg;
std::size_t shown = 0;
for (const package::ExcludedEntry& e : excluded) {
if (maxLines != 0 && shown == maxLines) {
msg += " ... (" + std::to_string(excluded.size() - shown) +
" more, listed in the console)\n";
break;
}
msg += entryLine(e);
++shown;
}
return msg;
}
void reportOutcome(const ExportOutcome& out, const std::string& destPath) {
switch (out.status) {
case ExportStatus::Written:
ShowConsoleMsg(("ReaSampler export: wrote " + std::to_string(out.entriesWritten) +
" entry/entries (" + std::to_string(out.bytesWritten) +
" bytes) to " + destPath + "\n")
.c_str());
return;
case ExportStatus::SourceReadFailed:
ShowConsoleMsg(("ReaSampler export: ABORTED -- \"" + out.offendingName +
"\" could not be read. Nothing was written.\n")
.c_str());
return;
case ExportStatus::SourceChanged:
ShowConsoleMsg(("ReaSampler export: ABORTED -- \"" + out.offendingName +
"\" changed on disk while the package was being written. "
"Nothing was written; run the export again.\n")
.c_str());
return;
case ExportStatus::EncodeFailed:
ShowConsoleMsg("ReaSampler export: ABORTED -- this bank could not be encoded "
"as a package. Nothing was written.\n");
return;
// Both refusals are re-derived from a FRESH plan, so reaching them after the
// survey means the bank changed under the export, not that the user declined.
case ExportStatus::RefusedIncomplete:
case ExportStatus::RefusedUnrepresentable:
ShowConsoleMsg("ReaSampler export: ABORTED -- the bank changed between the "
"report and the write. Nothing was written; run the export "
"again.\n");
return;
case ExportStatus::RefusedDestinationExists:
ShowConsoleMsg("ReaSampler export: cancelled -- nothing was written.\n");
return;
case ExportStatus::NoSuchBank:
ShowConsoleMsg("ReaSampler export: that bank no longer exists.\n");
return;
case ExportStatus::NoProjectDir:
ShowConsoleMsg(kUnsavedProjectMsg);
return;
case ExportStatus::WriteFailed:
ShowConsoleMsg(("ReaSampler export: FAILED writing " + destPath +
". No package was left behind; any file already at that path is "
"untouched.\n")
.c_str());
return;
}
}
} // namespace
void doBankPackageExport(const ReaSamplerSession& session, const std::string& bankId) {
const std::string projectDir = currentProjectDir();
if (projectDir.empty()) {
ShowConsoleMsg(kUnsavedProjectMsg);
return;
}
// Report before acting, and before the picker opens: a refusal the user cannot
// act on should not cost them a trip through a save dialog first.
const ExportSurvey survey = surveyBankExport(session, projectDir, bankId);
if (!survey.bankFound) {
ShowConsoleMsg("ReaSampler export: no such bank.\n");
return;
}
const std::string bankName = survey.plan.manifest.bankDisplayName;
bool allowIncomplete = false;
if (survey.plan.verdict == package::ExportVerdict::Refused) {
ShowConsoleMsg(("ReaSampler export: ABORTED -- \"" + bankName +
"\" holds index record(s) a package cannot carry. Nothing was "
"written.\n" +
excludedManifest(survey.plan.excluded, 0))
.c_str());
return;
}
if (survey.plan.verdict == package::ExportVerdict::Incomplete) {
const std::string headline =
"ReaSampler export: \"" + bankName + "\" has " +
std::to_string(survey.plan.excluded.size()) +
" entry/entries whose file is missing or unreadable:\n";
ShowConsoleMsg((headline + excludedManifest(survey.plan.excluded, 0)).c_str());
if (!confirmed(headline + excludedManifest(survey.plan.excluded, 10) +
"\nExport the " +
std::to_string(survey.plan.manifest.entries.size()) +
" present entry/entries anyway?",
"ReaSampler: incomplete bank")) {
ShowConsoleMsg("ReaSampler export: cancelled -- nothing was written.\n");
return;
}
allowIncomplete = true;
}
// The bank's own name, not the project's: the artifact is a bank, and a user
// exporting three banks from one project needs three distinguishable files.
const std::string suggested =
projectDir + "/" + capture::sanitizeStem(bankName) + ".rsbank";
std::string dest;
bool appended = false;
if (!pickPackageSavePath(suggested, dest, &appended)) return; // user cancelled the picker
ExportRequest req;
req.projectDir = projectDir;
req.bankId = bankId;
req.destAbsPath = dest;
req.exportTimestamp = static_cast<std::int64_t>(std::time(nullptr));
req.allowIncomplete = allowIncomplete;
// The dialog's own overwrite confirm covered exactly this path when the picker
// did not need to append `.rsbank` to reach it — asking again would be a second
// prompt for the same consent. An appended path is one the dialog never saw, so
// that case still falls through to exportBank's own refusal and the confirm below.
req.allowOverwrite = !appended;
ExportOutcome out = exportBank(session, req);
if (out.status == ExportStatus::RefusedDestinationExists) {
if (!confirmed("A file already exists at:\n\n " + dest +
"\n\nReplace it with this bank package?",
"ReaSampler: replace package")) {
ShowConsoleMsg("ReaSampler export: cancelled -- nothing was written.\n");
return;
}
req.allowOverwrite = true;
out = exportBank(session, req);
}
reportOutcome(out, dest);
}
} // namespace reasampler
+18
View File
@@ -0,0 +1,18 @@
#pragma once
// package_export_action — the "export bank as package" action body: survey and
// report first, confirm what is absent, pick a destination, write. Every prompt in
// the flow lives here; shell/package/export_bank stays promptless. Registration and
// dispatch for its FOREVER-STABLE id ride main.cpp's action table.
#include <string>
namespace reasampler {
class ReaSamplerSession;
// Exports one bank (the pool included — it is structurally a bank) to a .rsbank the
// user picks. Read-only against the project: the session is const, so no ext-state
// write, generation bump or undo point is reachable from here.
void doBankPackageExport(const ReaSamplerSession& session, const std::string& bankId);
} // namespace reasampler
+188
View File
@@ -0,0 +1,188 @@
// package_import_action.cpp — see package_import_action.h for the contract.
// main.cpp owns the API pointers; this TU gets them extern.
#include "shell/actions/package_import_action.h"
#include <string>
#include "core/package/import_plan.h"
#include "core/package/package_format.h"
#include "core/version/app_version.h"
#include "shell/package/import_bank.h"
#include "shell/package/package_pickers.h"
#include "shell/persist/session.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_ShowMessageBox
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
constexpr const char* kTitle = "ReaSampler: import bank package";
std::string quoted(const std::string& s) { return "\"" + s + "\""; }
// The message body itself is core/package::ledgerRefusalMessage — a pure
// (LedgerRefusal, namespace) -> string fold, testable without a DAW. This TU only
// supplies the channel-correct namespace and the console call.
void reportLedgerRefusal(package::LedgerRefusal refusal) {
ShowConsoleMsg(
package::ledgerRefusalMessage(refusal, version::extStateNamespace()).c_str());
}
// The refusal a user can act on names all three: what the package needs, what this
// build reads, and which build wrote it. Any two of them leave them stuck.
void reportTooNew(const ImportBankResult& r) {
const bool knownWriter = !r.header.writerVersion.empty();
const std::string writer =
knownWriter ? "ReaSampler " + r.header.writerVersion : std::string("an unidentified build");
std::string msg =
"Cannot import this bank package.\n"
"It was written by " + writer + " and needs package format " +
std::to_string(r.header.minReaderVersion) + " or newer.\n"
"This build (" + version::appVersion() + ") reads package format " +
std::to_string(package::kPackageFormatVersion) + ".\n"
"Nothing was imported. ";
// "Install <writer> or newer" reads fine when writer is a real semver; it does not
// when writer is the "unidentified build" filler, so that case gets its own sentence.
msg += knownWriter ? "Install " + writer + " or newer and try again."
: "Install a newer version of ReaSampler and try again.";
ShowMessageBox(msg.c_str(), kTitle, 0);
}
void reportSuccess(const ImportBankResult& r) {
std::string detail = "ReaSampler import: imported " + std::to_string(r.landedCount) +
" sample(s) into a new bank: " + quoted(r.bankDisplayName);
if (r.bankNameAdjusted)
detail += " (a bank named " + quoted(r.seedBankName) +
" already exists in this project)";
detail += ".\n";
// Two distinct triggers (core/package::ImportPlan), reported as two counts rather
// than folded into one ambiguous "already taken, or not spelled right" line.
if (r.collisionRenameCount > 0) {
detail += " " + std::to_string(r.collisionRenameCount) +
" file(s) landed under a freshly minted name (the package's own name "
"was already taken in the bank folder). An existing bank file is "
"never overwritten.\n";
}
if (r.sanitizeRenameCount > 0) {
detail += " " + std::to_string(r.sanitizeRenameCount) +
" file(s) landed under a freshly minted name (not spelled the way "
"this bank spells a file).\n";
}
if (r.collapsedCount > 0) {
// "Already present" here can only mean a duplicate BY CONTENT inside this same
// package (Ε-F2: import never consults another bank's hashes) — deliberately
// reworded from bank-package.md:448's "already present" phrasing, which reads
// as "already in your project" and is misleading in this direction.
detail += " " + std::to_string(r.collapsedCount) +
" sample(s) duplicated another entry in this same package by content "
"and were written once.\n";
}
detail += "One undo removes the imported bank and its entries. It does NOT delete "
"the imported files -- they stay in the bank folder, referenced by "
"nothing, until a prune reclaims them.\n";
ShowConsoleMsg(detail.c_str());
// The console carries the copyable detail; the box makes the outcome unmissable.
const std::string summary = "Imported " + std::to_string(r.landedCount) +
" sample(s) into a new bank: " +
quoted(r.bankDisplayName) + ".";
ShowMessageBox(summary.c_str(), kTitle, 0);
}
void reportRollback(const RollbackResult& rollback, std::string& msg) {
if (rollback.failedCount > 0) {
msg += "\n" + std::to_string(rollback.failedCount) +
" partly-imported file(s) could not be removed and are still in the bank "
"folder. They are referenced by no bank; a prune will reclaim them.";
}
}
void report(const ImportBankResult& r) {
switch (r.outcome) {
case ImportOutcome::Landed:
reportSuccess(r);
return;
case ImportOutcome::TooNew:
reportTooNew(r);
return;
case ImportOutcome::NoProject:
ShowMessageBox("Save the project before importing a bank package -- an "
"unsaved project has no bank folder to import into.",
kTitle, 0);
return;
case ImportOutcome::Unreadable:
ShowMessageBox("That file could not be opened. Nothing was imported.",
kTitle, 0);
return;
case ImportOutcome::Malformed:
// Distinct from TooNew on purpose: the recoveries are opposite -- one is
// "install a newer build", this one is "get an intact copy".
//
// bank-package.md:443 asks for a separate "This package is not well-formed"
// message when an entry name carries a separator / ".." / an absolute form.
// Not implemented: deserializeManifest returns one indistinguishable nullopt
// for that and for ordinary corruption, so it folds into this generic box.
// The binding spec (PLAN.md:2678) only requires Malformed != TooNew, which
// this still satisfies -- that product-doc row is knowingly left open, not
// silently missed.
ShowMessageBox("This file is not a readable bank package (corrupt or "
"truncated). Nothing was imported.",
kTitle, 0);
return;
case ImportOutcome::IntegrityFailed: {
std::string msg = "This bank package is damaged (entry " +
quoted(r.failedEntryName) +
" failed its integrity check). Nothing was imported.";
ShowMessageBox(msg.c_str(), kTitle, 0);
return;
}
case ImportOutcome::WriteFailed: {
std::string msg = "Import failed and was rolled back. Nothing was added.";
reportRollback(r.rollback, msg);
ShowMessageBox(msg.c_str(), kTitle, 0);
return;
}
case ImportOutcome::IndexRejected: {
std::string msg = "The bank index rejected the import. Nothing was added.";
reportRollback(r.rollback, msg);
ShowMessageBox(msg.c_str(), kTitle, 0);
return;
}
}
}
// FIRST, before the picker: making the user find and choose a file we have already
// decided to refuse is the wrong order.
bool ledgerPermits(ReaSamplerSession& session) {
const package::LedgerRefusal refusal =
package::importLedgerRefusal(session.ledgerStatus());
if (refusal == package::LedgerRefusal::None) return true;
reportLedgerRefusal(refusal);
return false;
}
} // namespace
std::string doImportBankPackage(ReaSamplerSession& session) {
if (!ledgerPermits(session)) return {};
std::string path;
if (!pickPackageForImport(path) || path.empty()) return {};
const ImportBankResult r = importBankPackage(session, path);
report(r);
return r.outcome == ImportOutcome::Landed ? r.bankId : std::string{};
}
std::string doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath) {
if (packageAbsPath.empty()) return {};
if (!ledgerPermits(session)) return {};
const ImportBankResult r = importBankPackage(session, packageAbsPath);
report(r);
return r.outcome == ImportOutcome::Landed ? r.bankId : std::string{};
}
} // namespace reasampler
+22
View File
@@ -0,0 +1,22 @@
#pragma once
// package_import_action — the bindable/menu/drop skin over importBankPackage: the
// ledger gate (which runs BEFORE the picker, so a refusal never costs the user a file
// choice), the picker itself, and every message the import produces.
#include <string>
namespace reasampler {
class ReaSamplerSession;
// Gate, pick, import, report. The bound action and the panel's bank menu both call
// this. Returns the minted bank id on a landed import, "" otherwise (cancelled,
// refused, or failed) — a caller that wants to focus the new bank (mirroring
// doCreateBank) checks the return rather than reaching back into ImportBankResult.
std::string doImportBankPackage(ReaSamplerSession& session);
// Same, for a .rsbank already named by the user — the panel's file-drop route. The gate
// still runs first; only the picker is skipped. Same return contract as doImportBankPackage.
std::string doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath);
} // namespace reasampler
+20 -8
View File
@@ -36,7 +36,14 @@ detail not covered there:
- **`renderOffline` is the one seam both a fresh capture and a recipe replay
cross**, which is why the refusal and both transient guards live there rather
than in the action bodies — anything placed in `ResolveScopeSource` alone would
miss `RunRecaptureFromSource` entirely.
miss `RunRecaptureFromSource` entirely. The bounds mode is inside the backend
that seam calls, for the same reason: a replay must hand its window over exactly
the way a fresh capture does.
- **The render window travels in the project's own TIME SELECTION**
(`RENDER_BOUNDSFLAG=2`), so `capture` snapshots and restores that selection on every
exit path like any other state it borrows. The custom-bounds field floors the window
to the millisecond and must not come back — why, in
`src/core/capture/render_settings.h`'s `kRenderBoundsTimeSelection`.
- **FX-bypass guard ordering.** `scope_resolve` reads the M10 provenance-assembly
inputs (track/item selection, FX-chain identity) BEFORE the FX-bypass guard
neutralizes the in-scope chain — provenance must see the chain as it really is,
@@ -45,24 +52,29 @@ detail not covered there:
`capture_realtime_shell` cannot block REAPER's UI for the duration of a realtime
record, so `begin`/`tick`/`abort` are async by construction and the temp-track +
send recipe lives in the shell, not the pure core.
- **`RunInsertSelected` is the one deliberate exception to capture-never-places**
(see `capture_orchestrator` below) — every other capture entry point writes only
a file + index entry.
- **This directory hosts TWO placing paths, and neither is a capture placing
itself.** `RunInsertSelected` (see `capture_orchestrator` below) places a *bank
sample*, on demand, which is why it is the deliberate exception to
capture-never-places. `render_in_place` places a render that never entered the
bank — the third verb (arrange → arrange, root `CLAUDE.md` §The load-bearing
principle). Every other entry point here writes only a file + index entry, and no
capture may ever grow a place step.
## Modules
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). A refused render is MOVED to `<projectDir>/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open; the bank never INDEXES it either way — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened).
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` — destination-dependent: on `CaptureDestination::Bank` (the default) the `Sample` is handed to `bank_model`; on `ProjectMedia` the file lands outside the bank and the caller (`render_in_place`) discards the returned `Sample`. It also owns the two file-side steps both backends share, in this order: `collapseCapturedFileToMono` (the lossless mono collapse, applied to the landed file) and `stampCaptureSample`, which measures the channel count off that same file so the entry and the audio cannot disagree. And `captureNameFor` — the impure local-clock read the entry points call to build a request's label + stem, kept out of the pure `core/capture/capture_name` composition it feeds.
- `render_bounds_gate` (`shell/capture`) — the exact-bounds verdict on a landed offline render and the refusal's file handling, split off `capture.cpp` on the render-vs-judge seam. Refuses a frame count that is not the window's AND a file whose frames cannot be measured at all (an invalid layout used to skip the gate and land with an unknown channel count). Judges `TailMode::None` only — Auto/Manual add frames by design, and an unmeasurable render still lands under those two (`docs/TODO.md`). Refusal handling is destination-aware (`render_bounds_gate.h`): on `CaptureDestination::Bank`, a refused render is MOVED to `<projectDir>/reasampler_refused/` rather than deleted, so the frames it did print survive for diagnosis while the short-render root cause is open — but a failed move leaves the file sitting unindexed in the bank folder itself, not `reasampler_refused/` (the console message says which happened); the bank never INDEXES it either way. On `CaptureDestination::ProjectMedia` the file is left exactly where the renderer wrote it — no move, no bank folder, no bank language in the message — because that render is the project's own media, not the tool's (`docs/product/render-in-place.md` "Where the file goes").
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain). Also the one place a source track's NAME is read (`trackName`, via `GetTrackName` — chosen over `P_NAME` because it already answers REAPER's `"Track N"` convention for an unnamed track), landed on `ResolvedSource::trackNames` parallel to `sourceTracks` and composed into the capture's label + stem by the pure `core/capture/capture_name`.
- `render_selection` (`shell/capture`) — the transient track selection a selected-tracks render (`&128`) requires, as a stack RAII guard: REAPER prints whatever tracks are selected, so `renderOffline` makes the request's own tracks BE the selection for the render's duration and restores the user's set on every exit path. Engaged ONLY for that source mode, which leaves a stated residual: a `&32` selected-items render still prints whatever ITEMS the user has selected. Live captures are unaffected (that selection is the source), but a recipe replay of a `SelectedItems` capture renders against whatever happens to be selected then — the recipe stores tracks and a range, never item GUIDs, so this guard cannot close it. Filed in `docs/TODO.md`.
- `render_isolation` (`shell/capture`) — the transient upstream silencing a ranged ITEM render needs, as a stack RAII guard alongside the two above: the selected-tracks source prints everything flowing INTO the track, so each direct folder child's `B_MAINSEND` and each of the track's receives' `B_MUTE` are cut for the render and restored on every exit path. Direct children only — a grandchild reaches the track through the child that owns it. The child-set walk is pure (`core/capture/track_topology`).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the capture family's deliberate exception to capture-never-places — see the Invariants section above for `render_in_place`, the directory's other placing path, which sits outside the capture family entirely).
- `bake_land` (`shell/capture`) — the EXTENSION's half of the resample chain, the SCAN PASS: scans every open project tab for pending `rsbake_*` requests, lands the ones belonging to the project this session has loaded (via `bake_landing`, below), and refuses the rest with `WrongProject` — one undo point for the batch, each answered over its own key inside the invoking instance's synchronous action call. It owns every ext-state read and write in the chain. The per-key verdict itself is NOT this TU's: it is `core/wire`'s pure `classifyBakeScan`, so this shell only enumerates, reads, and applies — counting every verdict into a `wire::BakeScanTally` as it goes, printing `wire::describeBakeKey` for EVERY enumerated key (the only thing that names which key is whose) plus `wire::describeBakeScan` whenever any key went unanswered or any answer's write was not confirmed, in one `ShowConsoleMsg`. It PROVES every write — answer or stale-clear — by reading the key back (`wire::extStateWriteLanded`, whose home is `core/wire/ext_state_read.h`); an answer that did not land is the one no-answer the tally alone cannot show. That proof is three-valued (`wire::BakeWriteProof`): a read-back that overflowed, or a throw AFTER the `SetProjExtState` call, reports Unknown; a throw BEFORE it reports Rejected, because the write is then known not to have been made. Each key is materialized before any answer is written, so no `SetProjExtState` in this action mutates a set the enumerator is still walking. Answers are held UNENCODED until after the pass's single persist, so a landing whose pass never got its persist through is answered as a failure rather than as an `Ok` no reload would honour — `wire::bakeLandingAfterPersist` is the ONE route to a `Banked` landing, and no path here (dedup included) may assign that word itself. The undo block is stack RAII (`UndoBlock`). Both loops are guarded: a throw in the scan still writes the answers already prepared, and a throw in the write-back loop still prints the lines already accumulated — no path through this action can end in a silent console. It RENDERS NOTHING — the instrument already did, through its own engine in its own process, which is what makes the baked audio the sound the user approved and what keeps the voice engine out of the extension's link graph.
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
- `bake_landing` (`shell/capture`) — landing ONE bake request, split off `bake_land` on the one-request / whole-pass seam; touches no REAPER API at all. It takes the lossless mono collapse on the staged BUFFER (`wav_codec::applyMonoCollapse`, the same predicate the two backends' file-side `collapseCapturedFileToMono` runs) before the hash and before the channel-count read, so the hash, the entry and the written file all come from one buffer — a dead-center render lands 1-channel like any other dead-center capture. Non-mutating `prepareLanding` and mutating `commitLanding` sit under separate catches in `attemptLanding` — a throw before anything was written is a clean refusal, a throw after it is reported as possibly partial. Replace-vs-add comes from `tracking::resampleLanding`; a replace keeps the entry's id and slot and never deletes the superseded file. Hash-dedup applies on the add path only, before the disk write, matching `updateSampleInPlace`'s "an in-place refresh is not an insert" — and a dedup hit still rides the pass's persist, because the entry it points at may be one the same pass just added. A refused index withdraws the bytes this call had just written — the self-cleanup carve-out from prune's deletion authority, stated in `prune_fs.cpp`'s header. It never persists: the pass does that once for its whole batch, which is why no landing may report itself as banked.
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
- `capture_realtime_finalize` (`shell/capture`) — the file-side half of the realtime-record shell (Q-W3, T4-08): discovers the file REAPER actually recorded, moves it into the bank, runs the Auto-tail PCM decay-scan trim, and populates the finished `Sample`.
- `render_in_place` (`shell/capture`) — the third verb, arrange → arrange: renders the selected track's output over the resolved range through `renderOffline` with `CaptureDestination::ProjectMedia`, then places the result on a brand-new sibling track at the render window's exact start (unsnapped — this placement IS the null test performed automatically), clones the source's colour and its name through the idempotent `captureTrackName`, and settles both tracks' modes in ONE `UNDO_STATE_ALL` block. Sibling nesting comes from the pure `core/capture/track_topology::siblingPlacement`. The source is tagged Design and the result track + its items are tagged `kArrangeModeId` **explicitly and unconditionally** — never `view.activeModeId()`, and never `untag()`, because the panel's auto-tag detector defers to a membership RECORD. It reads and writes NOTHING in the bank: no `session.bank()`, no `session.book()`, no `recordCreated`, no `bumpBankGeneration`; the `Sample` the backend returns is discarded and its `relativePath` is empty by construction. Traffic is one-way — capture may borrow this render, this placement may never be borrowed back into a capture.
- `insert` — placement via `InsertMedia`. **Conform-to-project-tempo is an explicit opt-in flag, never silent stretching.** The mono collapse needs no change here: `insert.cpp` passes only a path to `InsertMedia`, and REAPER derives the item's channel count from the file itself — a 1-channel WAV yields a mono item for free.
- `provenance_shell` — FX-chain identity queries via `TrackFX_*`/`TakeFX_*` APIs; feeds the pure `provenance` fingerprint builder. Stamps `Sample.provenance` on capture; ambiguous/mixed cases record nothing conservatively.
- `track_guid` — shared `MediaTrack*` → canonical GUID-string formatter; single source of truth for membership keys.
+7 -2
View File
@@ -13,7 +13,7 @@
#include <vector>
#include "core/capture/capture_paths.h" // deriveBankPaths
#include "core/capture/wav_codec.h" // parseWavLayout / hashWavContent
#include "core/capture/wav_codec.h" // applyMonoCollapse / hashWavContent
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/model/resample_name.h" // the iteration-chain display name
@@ -85,7 +85,12 @@ PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& pr
"the staged render was unreadable", request.generation);
return prep;
}
const WavLayout layout = parseWavLayout(prep.bytes);
// Before the hash, so nothing measures a buffer it won't write.
// `staged.collapsed` goes unread: a rebuild that fails to reparse reverts to the
// staged bytes inside applyMonoCollapse itself, so there is nothing left here to react to.
CollapsedWav staged = applyMonoCollapse(std::move(prep.bytes));
prep.bytes = std::move(staged.bytes);
const WavLayout layout = staged.layout;
if (!layout.valid || layout.frameCount() == 0) {
prep.settled = refuseBake(BakeStatus::StagedMissing,
"the staged render is not a usable WAV", request.generation);
+5 -4
View File
@@ -1,8 +1,9 @@
#pragma once
// bake_landing — landing ONE bake request into the loaded project's bank: read and hash the
// staged WAV, resolve replace-vs-add, write the file, index it, seed its lineage. The scan
// pass that finds requests across the open tabs and answers them is `bake_land`; this is
// what it calls per request, and it neither reads nor writes an ext-state key.
// bake_landing — landing ONE bake request into the loaded project's bank: read the staged
// WAV, collapse it losslessly to mono when it is dual-mono, hash it, resolve replace-vs-add,
// write the file, index it, seed its lineage. The scan pass that finds requests across the
// open tabs and answers them is `bake_land`; this is what it calls per request, and it
// neither reads nor writes an ext-state key.
#include <cstdint>
#include <string>
+71 -16
View File
@@ -6,8 +6,9 @@
// the one TU that defines the API pointers; here they are extern.
//
// Drives the RENDER_* project settings via GetSetProjectInfo/_String (source-
// selection bits come from the pure render_settings mapping), snapshots and
// restores every setting it changes, triggers a render, then populates a Sample.
// selection bits come from the pure render_settings mapping) plus the project time
// selection, which is where the render window itself travels; snapshots and restores
// every one of them, triggers a render, then populates a Sample.
// Source-agnostic: never reads the DAW selection itself, only the CaptureRequest
// the caller resolved. RENDER_ADDTOPROJ&1 is cleared on every path — never
// inserts into the arrange.
@@ -38,6 +39,7 @@
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetProjectPathEx
#define REAPERAPI_WANT_GetSetProjectInfo
#define REAPERAPI_WANT_GetSetProjectInfo_String
#define REAPERAPI_WANT_GetSet_LoopTimeRange
@@ -60,10 +62,6 @@ namespace {
// project — why we set them all explicitly first.
constexpr int kActionRenderUsingMostRecentSettings = 42230;
// RENDER_BOUNDSFLAG 0 = custom time bounds (we set STARTPOS/ENDPOS ourselves
// for exact, unrounded bounds). SDK header ~3042.
constexpr double kBoundsCustom = 0.0;
// RENDER_TAILFLAG/TAILMS/NORMALIZE/TRIMEND are driven from the pure
// tailRenderSettingsFor mapping (render_settings.h) in the tail-driving block below.
@@ -175,6 +173,26 @@ void restoreRenderSettings(const RenderSettingsSnapshot& s) {
GetSetProjectInfo(s.proj, "RENDER_TRIMEND", s.trimEnd, true);
}
// The project time selection, snapshotted and restored around the render that carries
// its window in it. Separate from ScopedRenderSettings because it is project state
// rather than a RENDER_* setting.
// GetSet_LoopTimeRange has no project parameter (SDK header ~2670) — it acts on the
// active project, which is the one capture() already resolved and renders into.
struct ScopedTimeSelection {
double start = 0.0;
double end = 0.0;
ScopedTimeSelection() { GetSet_LoopTimeRange(false, false, &start, &end, false); }
~ScopedTimeSelection() {
// Copies: the setter takes non-const pointers, so the snapshot must not be
// what it writes through.
double s = start, e = end;
GetSet_LoopTimeRange(true, false, &s, &e, false);
}
ScopedTimeSelection(const ScopedTimeSelection&) = delete;
ScopedTimeSelection& operator=(const ScopedTimeSelection&) = delete;
};
// RAII wrapper: guarantees restore on every return path from capture().
struct ScopedRenderSettings {
RenderSettingsSnapshot snap;
@@ -413,16 +431,50 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// Compute the tag ONCE — calling makeUniqueTag() twice would let the file
// stem and Sample.id diverge (the counter advances per call).
const std::string uniqueTag = makeUniqueTag("");
const BankPaths paths =
deriveBankPaths(projectDir, request.baseName, uniqueTag);
// Destination resolves HERE, after the save gate above, so an unsaved project is
// still prompted before any path arithmetic runs. ProjectMedia lands outside the
// bank folder and leaves relativePath empty — the Sample it produces indexes
// nothing (docs/product/render-in-place.md §"Where the file goes").
RenderPaths paths;
std::string relativePath;
if (request.destination == CaptureDestination::Bank) {
const BankPaths bank =
deriveBankPaths(projectDir, request.baseName, uniqueTag);
paths = RenderPaths{bank.absoluteDir, bank.fileName, bank.fileStem};
relativePath = bank.relativePath;
} else {
std::vector<char> recDir(4096, '\0');
GetProjectPathEx(proj, recDir.data(), static_cast<int>(recDir.size()));
paths = deriveRenderPaths(std::string(recDir.data()), request.baseName,
uniqueTag);
if (paths.absoluteDir.empty()) {
result.status = CaptureStatus::NoProject;
result.message = "Could not resolve the project's recording path.";
return result;
}
}
ScopedRenderSettings guard(proj);
ScopedTimeSelection tsGuard;
// Custom time bounds so the rendered length equals the requested range with
// NO rounding and NO added silence (unless a tail was explicitly requested).
GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG", kBoundsCustom, true);
// The window travels in the project's own time selection, which is what makes the
// rendered length the requested range with NO rounding and NO added silence (unless
// a tail was explicitly requested) — the custom-bounds field floors it to the
// millisecond (render_settings.h's kRenderBoundsTimeSelection).
//
// RENDER_STARTPOS/ENDPOS are written anyway, to the same window. The header
// (~3045-3046) documents them as mode-0-only, so on mode 2 this is a cheap,
// fully-restored (ScopedRenderSettings) defensive write against that
// documentation being an incomplete account of what the renderer reads.
GetSetProjectInfo(proj, "RENDER_BOUNDSFLAG",
static_cast<double>(kRenderBoundsTimeSelection), true);
GetSetProjectInfo(proj, "RENDER_STARTPOS", request.startSeconds, true);
GetSetProjectInfo(proj, "RENDER_ENDPOS", request.endSeconds, true);
{
double s = request.startSeconds, e = request.endSeconds;
GetSet_LoopTimeRange(true, false, &s, &e, false);
}
// TAILFLAG/TAILMS/NORMALIZE/TRIMEND from the pure mapping: None -> exact
// bounds + disable-all normalize; Auto -> 8s tail + surgical trim-end
@@ -453,6 +505,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
GetSetProjectInfo(proj, "RENDER_SRATE",
static_cast<double>(effectiveSampleRate), true);
}
GetSetProjectInfo(proj, "RENDER_CHANNELS",
static_cast<double>(request.channelCount), true);
@@ -502,7 +555,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// legitimately produce zero (docs/TODO.md "0-byte render" entry: before this check,
// Auto/Manual landed an empty file as CaptureStatus::Ok with channelCount == 0).
const BoundsVerdict emptyVerdict =
checkRenderedFileNotEmpty(expectedPath, projectDir);
checkRenderedFileNotEmpty(expectedPath, projectDir, request.destination);
if (emptyVerdict.refused) {
result.status = CaptureStatus::BoundsMismatch;
result.message = emptyVerdict.message;
@@ -538,7 +591,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// yield a different value and desync Sample.id from the file name.
s.id = "cap-" + uniqueTag + "-" + paths.fileName;
s.displayName = request.label();
s.relativePath = paths.relativePath; // project-relative (invariant)
s.relativePath = relativePath; // project-relative (invariant); empty off the bank
s.sourceMode = request.sourceMode;
s.sourceRange.startSeconds = request.startSeconds;
s.sourceRange.endSeconds = request.endSeconds;
@@ -553,12 +606,14 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// single played note, so no root note is derivable; loop points are set
// later by an explicit user action.
result.status = CaptureStatus::Ok;
result.sample = s;
result.status = CaptureStatus::Ok;
result.sample = s;
result.absolutePath = expectedPath;
result.message = "Captured [" +
std::to_string(request.startSeconds) + "s, " +
std::to_string(request.endSeconds) + "s] -> " +
paths.relativePath + monoCollapseSuffix(collapseOutcome);
(relativePath.empty() ? expectedPath : relativePath) +
monoCollapseSuffix(collapseOutcome);
return result;
}
+16
View File
@@ -30,6 +30,14 @@ enum class WavBitDepth {
Float32,
};
// Where the render lands. TWO VALUES, never a caller-supplied path string: the
// backend resolves each to a directory itself, which is what makes "write into the
// bank folder" inexpressible from the ProjectMedia side and vice versa.
enum class CaptureDestination {
Bank, // <projectDir>/reasampler_bank — every capture path
ProjectMedia, // the project's recording path — the render-in-place verb only
};
// One capture, independent of source mode.
struct CaptureRequest {
SourceMode sourceMode = SourceMode::MasterMix;
@@ -75,6 +83,9 @@ struct CaptureRequest {
// The one home for that fallback rule; both backends populate Sample::displayName
// from here rather than each spelling the condition out.
std::string label() const { return displayName.empty() ? baseName : displayName; }
// Default Bank: every existing entry point renders into the bank untouched.
CaptureDestination destination = CaptureDestination::Bank;
};
// Every failure is an explicit code, never a thrown exception across the REAPER boundary.
@@ -95,6 +106,11 @@ struct CaptureResult {
CaptureStatus status = CaptureStatus::RenderFailed;
Sample sample; // valid only when status == Ok
std::string message; // human-readable detail for the console log
// The file the render actually landed, absolute — the only handle a caller that
// banks nothing has on its own output (sample.relativePath is empty on the
// ProjectMedia destination). Set on the Ok path only.
std::string absolutePath;
};
// Deterministic offline-render backend: master mix / time selection / selected
+2 -1
View File
@@ -231,7 +231,8 @@ CaptureResult renderOffline(CaptureScope scope,
// caller can report success/failure. Load-bearing principle holds: writes a file +
// a bank index entry ONLY; never touches the arrange/timeline. Non-destructive: the
// out-of-scope FX/fader/pan chain is fully restored on every path (FxBypassGuard),
// and the backend restores every RENDER_* setting.
// and the backend restores every RENDER_* setting it changed plus the project time
// selection it borrowed to carry the render window.
//
// On success, res.sample.id carries the LANDED bank-index id (S8): the newly-added id
// on a fresh add, or the EXISTING entry's id on a hash-dedup collapse — so the S8
@@ -369,6 +369,11 @@ RealtimeRecordBackend::begin(const CaptureRequest& request,
// recordWindowEnd extends past the range end for a tail mode so the
// transport captures the decay; cursor + time selection are restored by restore().
// `[verify — DAW]` whether rs/re come back changed on this isSet=true call: the SDK
// header names both `double*` but documents no read-back semantics for either
// direction, and nothing here reads rs/re again after the call to notice. Lower
// stakes than the offline RENDER_* store: completion is driven by the play cursor
// reaching the range end (tick(), below), not by re-reading this pair.
double rs = request.startSeconds, re = st->recordWindowEnd_;
GetSet_LoopTimeRange(true, false, &rs, &re, false);
SetEditCurPos(request.startSeconds, false, false);
+39 -4
View File
@@ -45,6 +45,25 @@ std::string retainRefusedRender(const std::string& renderedPath,
renderedPath + ", indexed by nothing. Delete it when done.";
}
// ProjectMedia is the project's own media, never the bank's (docs/product/render-in-place.md
// "Where the file goes") -- a refusal takes no custody of it. No move, no bank folder, no
// mention of a bank the render was never headed for.
std::string leaveRefusedRenderInPlace(const std::string& renderedPath) {
return " The render was left where it was written, at " + renderedPath +
" -- delete it when done.";
}
// Dispatches the refusal's file-handling sentence by destination, so both verdict
// functions below state one true thing about the file rather than the bank sentence
// on every destination.
std::string refusalOutcome(const std::string& renderedPath,
const std::string& projectDir,
CaptureDestination destination) {
if (destination == CaptureDestination::ProjectMedia)
return leaveRefusedRenderInPlace(renderedPath);
return retainRefusedRender(renderedPath, projectDir);
}
} // namespace
std::string refusedRenderFolder(const std::string& projectDir) {
@@ -52,7 +71,8 @@ std::string refusedRenderFolder(const std::string& projectDir) {
}
BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
const std::string& projectDir) {
const std::string& projectDir,
CaptureDestination destination) {
BoundsVerdict v;
std::error_code ec;
const std::uintmax_t size = std::filesystem::file_size(renderedPath, ec);
@@ -61,7 +81,7 @@ BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
v.refused = true;
v.message = "Render at " + renderedPath + " is 0 bytes -- REAPER produced an empty "
"file, so there is nothing to check the requested range against." +
retainRefusedRender(renderedPath, projectDir);
refusalOutcome(renderedPath, projectDir, destination);
return v;
}
@@ -89,7 +109,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
"not be read (locked, missing, or a permissions error), its WAV "
"header did not parse, or it declared no sample rate -- so the "
"frames it holds were never checked against the requested range." +
source + retainRefusedRender(renderedPath, projectDir);
source + refusalOutcome(renderedPath, projectDir, request.destination);
return v;
}
@@ -99,6 +119,21 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
const long long actualFrames = static_cast<long long>(layout.frameCount());
if (renderHonoredBounds(expectedFrames, actualFrames)) return v;
// Says whether this shortfall has the known shape: the END alone floored to the
// millisecond, which is what REAPER's render was measured doing. Checked against the
// END only -- a refusal whose START is also off-grid and independently floored would
// not match this shape, and this note's silence on that refusal is this check not
// covering it. Excludes 0, which every sub-millisecond window (a legitimate day-one
// capture) also floors to, and which would otherwise match a render that produced
// nothing.
const long long msFlooredEnd =
msFlooredEndFrameCount(request.startSeconds, request.endSeconds, rate);
const std::string msNote =
(msFlooredEnd > 0 && actualFrames == msFlooredEnd)
? " Those are exactly the frames this window holds with its end floored to"
" the millisecond -- the shape REAPER's render was measured producing."
: std::string();
v.refused = true;
v.message = "Render produced " + std::to_string(actualFrames) +
" frames but the requested range is " + std::to_string(expectedFrames) +
@@ -108,7 +143,7 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
std::to_string(request.endSeconds) + "s) -> frame indices [" +
std::to_string(std::llround(request.startSeconds * rate)) + ", " +
std::to_string(std::llround(request.endSeconds * rate)) + ")." +
retainRefusedRender(renderedPath, projectDir);
msNote + refusalOutcome(renderedPath, projectDir, request.destination);
return v;
}
+12 -8
View File
@@ -10,9 +10,11 @@
namespace reasampler::capture {
// A refused render is MOVED out of the bank, not deleted: while the root cause of a
// short render is open (docs/TODO.md), the frames it did print are the evidence — and
// nothing may index a file the bank never accepted.
// On the Bank destination, a refused render is MOVED out of the bank, not deleted: while
// the root cause of a short render is open (docs/TODO.md), the frames it did print are
// the evidence — and nothing may index a file the bank never accepted. On ProjectMedia,
// the render is the project's own media (docs/product/render-in-place.md "Where the file
// goes"), so a refusal leaves it exactly where it was written — no move, no bank folder.
struct BoundsVerdict {
bool refused = false;
std::string message; // console text; meaningful only when refused
@@ -22,7 +24,8 @@ struct BoundsVerdict {
// frame count is not the window's (render_window::renderHonoredBounds owns the
// tolerance and its limits), or the file cannot be measured at all — an unmeasured
// render is not a verified one. TailMode::Auto/Manual add frames by design and are
// never judged here. `projectDir` is where a refused render is parked.
// never judged here. `projectDir` and `request.destination` together decide where a
// refused render is parked.
BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
const std::string& projectDir,
const CaptureRequest& request);
@@ -31,11 +34,12 @@ BoundsVerdict checkRenderedBounds(const std::string& renderedPath,
// never legitimately produce zero), independent of and ahead of the TailMode::None-only
// gate above, which does not run on Auto/Manual at all.
BoundsVerdict checkRenderedFileNotEmpty(const std::string& renderedPath,
const std::string& projectDir);
const std::string& projectDir,
CaptureDestination destination);
// Where a refused render is retained -- exposed so a multi-unit caller (batch capture)
// can name the folder once without duplicating the subfolder name `checkRenderedBounds`
// and `checkRenderedFileNotEmpty` already use internally.
// Where a refused Bank-destination render is retained -- exposed so a multi-unit caller
// (batch capture, Bank-only) can name the folder once without duplicating the subfolder
// name `checkRenderedBounds` and `checkRenderedFileNotEmpty` already use internally.
std::string refusedRenderFolder(const std::string& projectDir);
} // namespace reasampler::capture
+231
View File
@@ -0,0 +1,231 @@
// render_in_place.cpp — see render_in_place.h.
//
// Includes reaper_plugin_functions.h WITHOUT REAPERAPI_IMPLEMENT — main.cpp is the
// one TU that defines the API pointers; here they are extern.
#include "shell/capture/render_in_place.h"
#include <string>
#include <vector>
#include "core/capture/capture_name.h" // captureTrackName
#include "core/capture/insert_plan.h" // computeInsertMode / InsertOptions
#include "core/capture/render_settings.h" // CaptureScope
#include "core/capture/tail_control.h" // TailSetting
#include "core/capture/track_topology.h" // siblingPlacement
#include "core/view/view_mode_model.h" // kArrangeModeId / kDesignModeId
#include "shell/capture/capture.h"
#include "shell/capture/capture_orchestrator.h" // renderOffline
#include "shell/capture/item_read.h" // itemGuid
#include "shell/capture/scope_resolve.h" // ResolveScopeSource / trackName
#include "shell/capture/track_guid.h" // guidString
#include "shell/panel/panel_input.h" // bankPanelTailSetting
#include "shell/persist/session.h"
#include "shell/view/view.h" // applyMode / mintManagedLanes
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_CountTrackMediaItems
#define REAPERAPI_WANT_CountTracks
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_GetCursorPosition
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetProjectPathEx
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_GetTrack
#define REAPERAPI_WANT_GetTrackColor
#define REAPERAPI_WANT_GetTrackMediaItem
#define REAPERAPI_WANT_InsertMedia
#define REAPERAPI_WANT_InsertTrackInProject
#define REAPERAPI_WANT_SetEditCurPos
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetOnlyTrackSelected
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_TrackList_AdjustWindows
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#include "reaper_plugin_functions.h"
namespace reasampler::capture {
namespace {
void refuse(const std::string& why) {
ShowConsoleMsg(("ReaSampler render in place: " + why + "\n").c_str());
}
// Every track's I_FOLDERDEPTH in track order — the flat delta list the pure
// sibling arithmetic reads.
std::vector<int> folderDepths(ReaProject* proj, int count) {
std::vector<int> depths;
depths.reserve(static_cast<std::size_t>(count < 0 ? 0 : count));
for (int i = 0; i < count; ++i) {
MediaTrack* tr = GetTrack(proj, i);
depths.push_back(tr ? static_cast<int>(
GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"))
: 0);
}
return depths;
}
int indexOfTrack(ReaProject* proj, int count, MediaTrack* wanted) {
for (int i = 0; i < count; ++i)
if (GetTrack(proj, i) == wanted) return i;
return -1;
}
void setTrackName(MediaTrack* tr, const std::string& name) {
// GetSetMediaTrackInfo_String takes a writable buffer even on the set path.
std::vector<char> buf(name.begin(), name.end());
buf.push_back('\0');
GetSetMediaTrackInfo_String(tr, "P_NAME", buf.data(), true);
}
} // namespace
void RunRenderTrackInPlace(ReaSamplerSession& session) {
ResolvedSource src;
std::string why;
if (!ResolveScopeSource(CaptureScope::Track, src, why)) { refuse(why); return; }
if (src.sourceTracks.empty() || !src.sourceTracks.front()) {
refuse("no source track resolved"); return;
}
const TailSetting tail = bankPanelTailSetting();
const CaptureName name = captureNameFor(src.trackNames, /*ordinal=*/0, "capture");
CaptureRequest req;
req.sourceMode = SourceMode::SelectedTracks;
req.startSeconds = src.startSeconds; // exact bounds — no rounding
req.endSeconds = src.endSeconds;
req.wetDry = 1.0;
req.tailMode = tail.mode;
req.tailMs = tail.manualMs;
req.sampleRate = 0; // follow project rate
req.channelCount = 2;
req.bitDepth = WavBitDepth::Float32;
req.baseName = name.stemBase;
req.displayName = name.label;
req.destination = CaptureDestination::ProjectMedia;
// trackGuids left empty: they exist to stamp provenance onto a Sample this verb
// discards. A multi-track selection is refused inside renderOffline, keyed on the
// render source, so there is no check to add here.
const CaptureResult res = renderOffline(CaptureScope::Track, src.sourceTracks, req);
if (res.status != CaptureStatus::Ok) { refuse(res.message); return; }
MediaTrack* source = src.sourceTracks.front();
ReaProject* proj = EnumProjects(-1, nullptr, 0);
const int trackCount = CountTracks(proj);
const int srcIndex = indexOfTrack(proj, trackCount, source);
if (srcIndex < 0) {
refuse("the source track is no longer in the project; the render landed at " +
res.absolutePath + " but was not placed.");
return;
}
const SiblingPlacement place =
siblingPlacement(folderDepths(proj, trackCount), srcIndex);
// Read ONCE, and only to reapply the mode / decide whether the result landed
// visible — never to choose a tag. Both tags below are absolute.
const std::string activeMode = session.view().activeModeId();
Undo_BeginBlock2(nullptr);
// flags = 0, never 1: flags&1 adds default envelopes/FX, and a default chain
// would process a render that already carries the source's FX a second time.
InsertTrackInProject(proj, place.insertIndex, /*flags=*/0);
MediaTrack* fresh = GetTrack(proj, place.insertIndex);
if (!fresh) {
// InsertTrackInProject already mutated the project by this point, so the
// "no ext-state write -> discard" idiom does not apply here — a discard would
// leave the orphaned track un-undoable.
Undo_EndBlock2(nullptr, "ReaSampler: render in place (failed to create result track)",
-1);
refuse("could not create the result track; the render landed at " +
res.absolutePath + " but was not placed.");
return;
}
// Both writes or none — one alone lands the new track at the wrong nesting level,
// which is audible in both directions (see siblingPlacement).
if (place.precedingIndex >= 0) {
if (MediaTrack* preceding = GetTrack(proj, place.precedingIndex))
SetMediaTrackInfo_Value(preceding, "I_FOLDERDEPTH",
static_cast<double>(place.precedingDepth));
}
SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH",
static_cast<double>(place.newDepth));
// GetTrackColor returns the colour already OR'd with 0x1000000 and 0 for "no
// colour set", which I_CUSTOMCOLOR reads as unused — so one line clones a colour
// and the absence of one, with no branch.
SetMediaTrackInfo_Value(fresh, "I_CUSTOMCOLOR",
static_cast<double>(GetTrackColor(source)));
const std::string freshName = captureTrackName(trackName(source));
setTrackName(fresh, freshName);
// After every attribute write, per the SDK header's manual-panel-update caveat.
TrackList_AdjustWindows(false);
// Unsnapped and unrounded, deliberately: this placement IS the null test performed
// automatically, so snapping it to the grid would move the audio off the position
// it was rendered from. InsertOptions{} defaults give native length and no conform.
const double cursorPos = GetCursorPosition();
SetOnlyTrackSelected(fresh);
SetEditCurPos(src.startSeconds, false, false);
// InsertMedia's int return isn't SDK-documented; treated conservatively as
// 0 = failure, matching performArrangeDrop — an empty result track would
// otherwise be a silent no-op, which is exactly what this verb must not produce.
const bool placed =
InsertMedia(res.absolutePath.c_str(), computeInsertMode(InsertOptions{})) != 0;
SetEditCurPos(cursorPos, false, false);
// The new track is left selected, alone — in the headline case the source is being
// parked out of sight in the same gesture, so restoring the selection would leave
// the user selecting an invisible track.
// Absolute, not mode-following: the source parks on the bench, the result is an
// Arrange member whatever mode was active. Explicit records rather than untag(),
// because the record is what the panel's auto-tag detector defers to.
MembershipIndex& membership = session.view().membership();
membership.tag(guidString(source), kDesignModeId);
membership.tag(guidString(fresh), kArrangeModeId);
// The track is brand new, so its items are exactly the ones just placed. An
// untagged item would be handed to the detector, which tags to the active mode.
const int itemCount = CountTrackMediaItems(fresh);
for (int i = 0; i < itemCount; ++i) {
if (MediaItem* it = GetTrackMediaItem(fresh, i)) {
const std::string ig = itemGuid(it);
if (!ig.empty()) membership.tag(ig, kArrangeModeId);
}
}
mintManagedLanes(session.view(), nullptr);
applyMode(session.view(), activeMode, nullptr); // a reapply, never a switch
Undo_EndBlock2(nullptr, "ReaSampler: render selected track to a new track", -1);
// Persist outside the block. The offline render's own save gate already forced a
// saved project, so the Save-As-guarded persist the Design View actions need
// cannot have anything to prompt for here.
session.saveToActiveProject();
if (!placed) {
refuse("the render landed at " + res.absolutePath +
" but REAPER refused to place it — the new track is empty.");
return;
}
// Silent on success — the new track is the feedback. Except when it is not: fired
// outside Arrange the result track is parked, so a silent success would be
// indistinguishable from a no-op.
if (activeMode != kArrangeModeId) {
ShowConsoleMsg(("ReaSampler render in place: created \"" + freshName +
"\" in Arrange (switch to Arrange to see it).\n")
.c_str());
}
}
} // namespace reasampler::capture
+18
View File
@@ -0,0 +1,18 @@
#pragma once
// render_in_place — the third verb: render the selected track's output over the
// current range to the project's recording path, place it on a new sibling track at
// the exact position it was rendered from, and move the source to Design. The bank
// is never read, written, or notified (docs/product/render-in-place.md).
namespace reasampler {
class ReaSamplerSession;
}
namespace reasampler::capture {
// Resolves, renders, creates + dresses the sibling track, places the file, and
// settles both tracks' modes in one undo block. Silent on success (the new track is
// the feedback) except when the result lands invisible; ShowConsoleMsg on refusal.
void RunRenderTrackInPlace(ReaSamplerSession& session);
} // namespace reasampler::capture
+132
View File
@@ -0,0 +1,132 @@
# src/shell/package — package filesystem + dialog seam
## Scope
The filesystem and dialog acts behind bank-package export/import: streaming package
file I/O plus the file-status and exclusive-create acts (`package_io`), the
UTF-8 path conversion every one of them goes through (`package_path`), the landed-file
journal and its rollback delete (`package_rollback`), and the two file pickers
(`package_pickers`). Those are bytes-only — the package format (magic, manifest, entry
layout) is `core/package`'s business. Beside them sit both promptless verbs:
`export_bank` whole, and the import split so its decisions stay testable —
`import_landing` (REAPER-free) decides and writes, while `import_bank` owns the only
REAPER project state this directory touches (the ext-state persist, the undo block,
the generation bump). The export direction touches none of it: an export writes no ext
state, opens no undo point and never bumps the generation, and what prompting it needs
belongs to its skin (`shell/actions/package_export_action`), not this seam.
## Invariants
- **Paths cross this seam as UTF-8 narrow strings and are converted through
`utf8Path()` before ANY filesystem call.** This is not decoration: on Windows
`std::filesystem` decodes a narrow path through the runtime ANSI code page (measured
`GetACP() == 1252`), so a bare `fs::path(std::string)` turns `café.rsbank` into
`café.rsbank` or fails to open it. Every path a verb hands in or gets back —
including `listFolderFileNames`' results, which go through `pathToUtf8()` and never
`string()` — is UTF-8. `core/util/file_bytes` has the un-converted shape, which is
why `readFilePayload` reads through this module's own `PackageFileReader` instead.
- **Atomic package write, to the limit of a rename.** A package accumulates in a
`.rsbanktmp` sibling in the destination directory and reaches the destination only
through `commit()`'s rename (the mono-collapse temp+rename precedent). A failed,
aborted, or abandoned write leaves the destination absent or holding its prior
contents. This is process-crash atomic, NOT power-loss atomic: `commit()` flushes
and closes but does not `fsync`/`FlushFileBuffers`, so a power cut can still leave a
renamed-but-unflushed file. Deliberate — an fsync over a whole sample bank is a real
stall, and the failure this design targets is a refused or interrupted export.
- **Streaming, both ways — at most ONE entry's payload in memory.** Writes append
one payload at a time; reads seek and materialize one range at a time. The claim
is structural, not aspirational: every payload crosses this seam as a move-only
`PayloadBuffer`, and `PayloadBuffer::alive()` is the seam counter the tests
assert against. There is no read-whole-package or write-whole-package entry
point; do not add one.
- **An empty `PayloadBuffer` is a failure signal, never an entry.** It is the seam's
one "nothing to work with" branch, so both `PackageFileWriter::appendPayload` and
`writeFileExclusive` refuse it — appending it would let a verb commit framing that
claims bytes nobody wrote. `appendRaw(ptr, 0)` stays tolerated: framing has
legitimate zero-length edges.
- **No overwrite of a bank-folder file, ever — and the create is the check.**
`writeLandedFile` lands through `writeFileExclusive` (`O_EXCL` / `_O_EXCL`), so the
refusal of an occupied path is one atomic act rather than an `exists()` a concurrent
writer could win the race against. Collision handling (auto-rename) remains the
import plan's job upstream. The package writer itself DOES replace an existing
destination — the export save dialog's own overwrite confirm is the consent — and
that asymmetry is deliberate. **Closed, both halves.** `pickPackageSavePath`'s own
`.rsbank` re-append (see its Gotcha below) can turn a confirmed path `X` into a write
target `X.rsbank` that the dialog never asked about, so `exportBank` re-checks
`fileStatus()` on the path actually handed to `PackageFileWriter` — after any
extension append — and refuses `RefusedDestinationExists` until the caller sets
`allowOverwrite`. The caller does not always re-prompt to get there:
`pickPackageSavePath` reports whether it appended (`outAppended`), and
`package_export_action` pre-grants `allowOverwrite` whenever it did NOT — an
unappended path is exactly what the dialog's own confirm already covered, so asking
again would be a second prompt for the same consent. Only an appended path, one the
dialog never saw, still costs the verb's own confirm naming that exact path.
- **The rollback delete is prune's ONE carve-out, and only HALF of it is structural.**
The citation and the full discriminator live at `package_rollback.cpp`'s header.
"Did this call create it" is structural: only exclusively-created paths are
recorded, resolved absolute at record time so a later CWD change cannot re-aim the
delete. "Did anything ever reference it" is a **contract the import verb must
honour**: it MUST call `markIndexCommitted()` only AFTER the index write has
returned success — calling it before, then having that write fail, strands the
landed files with no index entry and a journal that now refuses to roll them
back — after which `rollback()` refuses and `writeLandedFile` refuses. (Destroying
an armed journal without calling either does NOT roll it back — see
`LandedFileJournal`'s own doc comment.) `import_bank` honours it: it calls
`markIndexCommitted()` only after `persistBankOp` has returned.
- **Integrity is proven before the first byte lands, not undone after.**
`landPackage` hashes every declared payload against the manifest and only then
creates the bank folder, so a damaged package costs no rollback at all and cannot
leave debris behind a rollback that itself failed. The second read of each payload
is deliberate on a once-per-gesture path — do not fold it into one
hash-and-write pass.
- **Both pickers ride `GetUserFileName`** — mode 1 for import, mode 0 for export.
There is no platform split and no fallback: `main.cpp` defines `REAPERAPI_IMPLEMENT`
without `REAPERAPI_MINIMAL` and aborts the extension load if any single name fails
to resolve, so a build that can load us cannot lack it.
## Modules
- `package_path` — header-only; the ONE UTF-8-narrow → `fs::path` conversion, so the encoding contract has a single enforcement point.
- `package_io` — every filesystem act the verbs need: `PayloadBuffer` (move-only payload + the `alive()` seam counter), `PackageFileWriter` (append-only temp+atomic-rename writer), `PackageFileReader` (seek-and-read one range per call, range-checked against the real file size), `readFilePayload` (one source file as one entry's payload), `fileStatus` (Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot), `writeFileExclusive` (exclusive create + write, self-cleaning on a partial write), and `listFolderFileNames` (bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW.
- `package_rollback``LandedFileJournal`: `writeLandedFile` (exclusive-create land, path resolved absolute, recorded on success only), `markIndexCommitted` (disarms the journal), and `rollback` (deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW.
- `package_pickers``pickPackageForImport` and `pickPackageSavePath`, both `GetUserFileName`; `pickPackageSavePath` also reports whether it appended `.rsbank` (`outAppended`), the signal `package_export_action` uses to skip a redundant overwrite confirm. Neither picker can be exercised in a unit test.
- `export_bank` — the promptless export verb, in three composable public steps: `surveyBankExport` (the read-only plan, report-before-acting), `digestSources` (measures each entry's length + `hashBytes` digest, one payload at a time), and `writePackageFile` (prefix, then each payload re-read and re-verified against that digest before it is appended, then commit). `exportBank` composes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives **const**: `saveToActiveProject`, `bumpBankGeneration` and `writeAssignmentRequest` are the session's only non-const acts, so a const session cannot reach them and "an export writes no ext state, opens no undo point and never bumps the generation" holds by the type rather than by memory (`pruneReclaim`, the sole file-deletion path, is const too and sits outside this claim). Reads the session through inline accessors only, which is why its tests link and run without a DAW.
- `import_landing` — the import's two halves that decide anything: `landPackage` (decode, plan, verify EVERY payload's digest, then land through the journal) and `applyImportedBank` (the new bank's entries plus a birth record per landed file, in one straight-line block). REAPER-free deliberately — all-or-nothing, integrity and birth-record behaviour are assertable without a DAW.
- `import_bank` — the promptless import verb over a live `ReaSamplerSession`: the project directory, the minted bank id, the `recordCreated` writer, and the one undo-batched persist. REAPER-facing, so it compiles into the extension module rather than into a library with a test target.
`package_round_trip_tests` is declared here with no library of its own: it drives the
same frozen corpus (`tests/fixtures/package_compat/`) through both verbs, which is where
export → import → export payload identity is proven.
## Gotchas
- A crash mid-export strands the `.rsbanktmp` sibling. It is not a `.rsbank` (no
picker filter matches it), and a later export to the same destination truncates it.
A crash mid-import strands a partial bank file under its real name instead — the
land is a direct exclusive create, not temp+rename. Either way the debris was never
recorded in the tracking ledger, so prune sees a foreign file (not owned, never an
orphan) and will not touch it; removal is by hand. `[verify — DAW]` whether the
import verb should pre-clean stale debris when it lands.
- The picker filter and mode arguments are spelled to `GetUserFileName`'s documented
pair format but are `[verify — DAW]` on all three platforms — neither picker is
exercised outside a live REAPER session. `GetUserFileName` also takes no owner
window, so dialog parenting is REAPER's to do; the superseded Win32 path passed
`GetMainHwnd()` explicitly. Also `[verify — DAW]`: whether mode 0's picker appends
an extension from `extension_list` when the user omits one — `pickPackageSavePath`
re-appends `.rsbank` itself so the returned path is correct regardless of how that
lands (the superseded Win32 path had `ofn.lpstrDefExt` for this; `GetUserFileName`
has no equivalent parameter). The re-append is suffix-blind: it only skips when the
path already ends in `.rsbank`, so a path carrying a DIFFERENT extension gets
`.rsbank` appended after it (`mybank.bak``mybank.bak.rsbank`), unlike the
superseded `ofn.lpstrDefExt`, which appended only when the path had no extension at
all. Defensible for a format-locked export, but a real divergence from the old
picker's behavior — whoever tests the picker under `[verify — DAW]` should expect
the double-extension result on a path that already has one.
- `pickPackageSavePath`'s `suggestedPath` doubles as the dialog's starting directory
when it is a full path. The verbs should seed it from the project directory —
passing a bare name leaves the dialog on REAPER's process working directory, which
is its install or resource path.
- `readRange(_, 0)` returns an empty buffer — indistinguishable from failure, by
design (the one "nothing to work with" branch). **Cross-track contract, not a local
rule:** a genuinely zero-length entry cannot round-trip through this seam, so
`core/package`'s format layer must not emit one.
+54
View File
@@ -0,0 +1,54 @@
# The filesystem + dialog seam for bank packages. package_io / package_rollback are
# REAPER-free (standard filesystem only), so the pure-library/test helpers fit and
# their tests run without a DAW. Both verbs live here too: export_bank whole, and the
# import's REAPER-free half (import_landing) the import's REAPER-facing half
# (import_bank.cpp) compiles into the extension module instead.
reasampler_pure_library(package_io SOURCES package_io.cpp)
reasampler_test(package_io LINK package_io)
reasampler_pure_library(package_rollback SOURCES package_rollback.cpp LINK PUBLIC package_io)
reasampler_test(package_rollback LINK package_rollback)
# export_bank reads the live session through ReaSamplerSession's INLINE accessors only,
# so it pulls in no REAPER-facing TU and its tests link (and run) without a DAW.
# bank_book / tail_control / origin_ledger / tracking_authority / prune_reconcile /
# app_version / view_mode_model are session.h's own transitive includes (BankBook::bank()
# in particular is out-of-line, in bank_book.cpp) declared here, on the library that
# actually needs them, rather than left for every consumer to enumerate.
reasampler_pure_library(export_bank
SOURCES export_bank.cpp
LINK PUBLIC export_plan bank_package package_io
PRIVATE capture_paths wav_codec bank_book tail_control origin_ledger
tracking_authority prune_reconcile app_version view_mode_model)
reasampler_test(export_bank
LINK export_bank bank_book slot_map view_mode_model tail_control origin_ledger
tracking_authority prune_reconcile app_version capture_paths)
# The import's decisions and its file half, both REAPER-free, so all-or-nothing,
# integrity and birth-record behaviour are assertable without a DAW. The REAPER-facing
# verb over them (import_bank.cpp) compiles into the extension module instead.
reasampler_pure_library(import_landing
SOURCES import_landing.cpp
LINK PUBLIC import_plan package_rollback bank_book PRIVATE bank_package wav_codec)
reasampler_test(import_landing LINK import_landing bank_package app_version wav_codec origin_ledger)
# The frozen compatibility corpus driven through both verbs in one process the round
# trip is export -> import -> export, so its link set is export_bank's plus the import
# half. Fixture path: see tests/package_fixtures.h.
reasampler_test(package_round_trip
LINK import_landing export_bank bank_package bank_book slot_map view_mode_model
tail_control origin_ledger tracking_authority prune_reconcile app_version
capture_paths wav_codec)
target_compile_definitions(package_round_trip_tests PRIVATE
REASAMPLER_PACKAGE_FIXTURE_DIR="${REASAMPLER_PACKAGE_FIXTURE_DIR}")
# The pickers call the REAPER API, so no test target can exercise them; declared as a
# library so the TU stays compiled. reaper_plugin.h pulls SWELL in on non-Windows.
add_library(package_pickers STATIC package_pickers.cpp)
target_include_directories(package_pickers PUBLIC ${REASAMPLER_SRC_DIR})
target_include_directories(package_pickers PRIVATE ${SDK_INC} ${WDL_INC})
if(NOT WIN32)
# Match the loadable modules: SWELL is provided by the host REAPER at runtime.
target_compile_definitions(package_pickers PRIVATE SWELL_PROVIDED_BY_APP)
endif()
+184
View File
@@ -0,0 +1,184 @@
// export_bank.cpp — see export_bank.h for the contract.
//
// wav_codec is called for hashBytes ONLY. Payload bytes are copied and hashed, never
// rebuilt, trimmed, normalized or collapsed — the capture path's mono collapse must
// not reach an export.
#include "shell/package/export_bank.h"
#include <cstddef>
#include <optional>
#include <utility>
#include "core/capture/capture_paths.h" // resolveBankFile — the index's relative -> absolute
#include "core/capture/wav_codec.h" // hashBytes
#include "core/model/bank_book.h"
#include "shell/package/package_io.h"
#include "shell/persist/session.h" // ReaSamplerSession — read through its inline book() only
namespace reasampler {
namespace {
package::SourceFileState stateOf(const std::string& absPath) {
switch (fileStatus(absPath)) {
case FileStatus::Present: return package::SourceFileState::Present;
case FileStatus::Unreadable: return package::SourceFileState::Unreadable;
case FileStatus::Absent: break;
}
return package::SourceFileState::Missing;
}
std::vector<std::string> absoluteSources(const std::string& projectDir,
const std::vector<std::string>& relativePaths) {
std::vector<std::string> out;
out.reserve(relativePaths.size());
for (const std::string& rel : relativePaths)
out.push_back(capture::resolveBankFile(projectDir, rel));
return out;
}
} // namespace
ExportSurvey surveyBankExport(const ReaSamplerSession& session,
const std::string& projectDir,
const std::string& bankId) {
ExportSurvey survey;
const Bank* bank = session.book().bank(bankId);
if (!bank) return survey;
survey.bankFound = true;
package::ExportInputs inputs;
inputs.bankDisplayName = bank->displayName;
inputs.slots = bank->slots;
for (const model::Sample& s : bank->index.all()) {
package::ExportCandidate c;
c.sample = s;
c.fileState = stateOf(capture::resolveBankFile(projectDir, s.relativePath));
inputs.candidates.push_back(std::move(c));
}
survey.plan = package::planExport(inputs);
return survey;
}
bool digestSources(package::PackageManifest& manifest,
const std::vector<std::string>& sourceAbsPaths,
std::string& outFailedName) {
outFailedName.clear();
if (sourceAbsPaths.size() != manifest.entries.size()) return false;
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
const PayloadBuffer payload = readFilePayload(sourceAbsPaths[i]);
if (payload.empty()) {
outFailedName = manifest.entries[i].fileName;
return false;
}
manifest.entries[i].byteLength = payload.size();
manifest.entries[i].byteHash = capture::hashBytes(payload.data(), payload.size());
}
return true;
}
ExportOutcome writePackageFile(const package::EncodedPackage& encoded,
const package::PackageManifest& manifest,
const std::vector<std::string>& sourceAbsPaths,
const std::string& destAbsPath) {
ExportOutcome out;
if (sourceAbsPaths.size() != manifest.entries.size() ||
encoded.layout.size() != manifest.entries.size()) {
out.status = ExportStatus::EncodeFailed;
return out;
}
// Every early return below abandons the writer through its destructor, which
// removes the temp and leaves the destination untouched.
PackageFileWriter writer(destAbsPath);
if (!writer.ok() || !writer.appendRaw(encoded.prefix.data(), encoded.prefix.size())) {
out.status = ExportStatus::WriteFailed;
return out;
}
std::uint64_t written = encoded.prefix.size();
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
const package::PackageEntry& entry = manifest.entries[i];
const PayloadBuffer payload = readFilePayload(sourceAbsPaths[i]);
if (payload.empty()) {
out.status = ExportStatus::SourceReadFailed;
out.offendingName = entry.fileName;
return out;
}
if (payload.size() != entry.byteLength ||
capture::hashBytes(payload.data(), payload.size()) != entry.byteHash) {
out.status = ExportStatus::SourceChanged;
out.offendingName = entry.fileName;
return out;
}
if (!writer.appendPayload(payload)) {
out.status = ExportStatus::WriteFailed;
return out;
}
written += payload.size();
}
if (written != encoded.totalSize || !writer.commit()) {
out.status = ExportStatus::WriteFailed;
return out;
}
out.status = ExportStatus::Written;
out.entriesWritten = manifest.entries.size();
out.bytesWritten = written;
return out;
}
ExportOutcome exportBank(const ReaSamplerSession& session, const ExportRequest& req) {
ExportOutcome out;
if (req.projectDir.empty()) {
out.status = ExportStatus::NoProjectDir;
return out;
}
const ExportSurvey survey = surveyBankExport(session, req.projectDir, req.bankId);
if (!survey.bankFound) {
out.status = ExportStatus::NoSuchBank;
return out;
}
out.bankDisplayName = survey.plan.manifest.bankDisplayName;
out.excluded = survey.plan.excluded;
if (survey.plan.verdict == package::ExportVerdict::Refused) {
out.status = ExportStatus::RefusedUnrepresentable;
return out;
}
if (survey.plan.verdict == package::ExportVerdict::Incomplete && !req.allowIncomplete) {
out.status = ExportStatus::RefusedIncomplete;
return out;
}
// The save dialog's own overwrite confirm covered the path the USER chose, which
// is not necessarily the path handed here (the picker re-appends `.rsbank`), so
// consent for the real target is re-taken by the skin.
if (!req.allowOverwrite && fileStatus(req.destAbsPath) == FileStatus::Present) {
out.status = ExportStatus::RefusedDestinationExists;
return out;
}
package::PackageManifest manifest = survey.plan.manifest;
manifest.exportTimestamp = req.exportTimestamp;
const std::vector<std::string> sources =
absoluteSources(req.projectDir, survey.plan.sourceRelativePaths);
if (!digestSources(manifest, sources, out.offendingName)) {
out.status = ExportStatus::SourceReadFailed;
return out;
}
const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
if (!encoded) {
out.status = ExportStatus::EncodeFailed;
return out;
}
ExportOutcome written = writePackageFile(*encoded, manifest, sources, req.destAbsPath);
written.bankDisplayName = out.bankDisplayName;
written.excluded = std::move(out.excluded);
return written;
}
} // namespace reasampler
+95
View File
@@ -0,0 +1,95 @@
// shell/package/export_bank — the promptless bank-export verb: survey, digest,
// stream, commit. No prompts and no message boxes (shell/actions/
// package_export_action is the skin). The session arrives CONST, which is how "an
// export writes no ext state, opens no undo point and never bumps the bank
// generation" is enforced rather than remembered — those three acts
// (saveToActiveProject, bumpBankGeneration, writeAssignmentRequest) are exactly the
// session members that are non-const (session.h:113,108,147). Constness does not
// block every mutation, though: pruneReclaim (session.h:140-141) is const and is
// the system's sole file-deletion path — irrelevant to export, but not something a
// const session forbids in general. Blocking I/O: UI-thread actions only.
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
#include "core/package/bank_package.h"
#include "core/package/export_plan.h"
namespace reasampler {
class ReaSamplerSession;
struct ExportRequest {
std::string projectDir; // absolute; the root the index's relative paths hang off
std::string bankId;
std::string destAbsPath; // the .rsbank to write
std::int64_t exportTimestamp = 0; // manifest envelope; the caller's clock read
// Both default false and are set ONLY after the skin's explicit confirm: one
// lists what is absent, the other names the destination being replaced.
bool allowIncomplete = false;
bool allowOverwrite = false;
};
enum class ExportStatus {
Written,
NoSuchBank,
NoProjectDir,
RefusedIncomplete,
RefusedUnrepresentable,
RefusedDestinationExists,
SourceReadFailed, // a file the plan classified Present would not read, or is empty
SourceChanged, // a payload's bytes moved between the digest pass and the stream pass
EncodeFailed,
WriteFailed,
};
struct ExportOutcome {
ExportStatus status = ExportStatus::WriteFailed;
std::size_t entriesWritten = 0;
std::uint64_t bytesWritten = 0;
std::string bankDisplayName;
std::vector<package::ExcludedEntry> excluded;
std::string offendingName; // the entry a SourceReadFailed / SourceChanged names
};
struct ExportSurvey {
bool bankFound = false;
package::ExportPlan plan;
};
// Report-before-acting: the same plan exportBank recomputes, with nothing written.
// Read-only against both the project and the filesystem.
ExportSurvey surveyBankExport(const ReaSamplerSession& session,
const std::string& projectDir,
const std::string& bankId);
// Fills each manifest entry's byteLength and byteHash from its source file — the
// digest pass, one payload in memory at a time. False with `outFailedName` set when a
// source will not read or is empty; a zero-length entry cannot round-trip the
// format's own seam, so it is a failure here rather than an entry.
bool digestSources(package::PackageManifest& manifest,
const std::vector<std::string>& sourceAbsPaths,
std::string& outFailedName);
// Streams one package to `destAbsPath`: the encoded prefix, then each payload re-read
// from `sourceAbsPaths` (parallel to `manifest.entries`) and re-checked against the
// length and digest recorded for it before it is appended — so the digest the
// manifest claims describes the bytes actually written, not the bytes a concurrent
// edit replaced. Any failure abandons the writer, leaving the destination absent or
// holding its prior contents.
//
// Public because that atomicity is this function's property: proving it needs a
// failure injected mid-stream, which is a call to this seam, not to exportBank.
ExportOutcome writePackageFile(const package::EncodedPackage& encoded,
const package::PackageManifest& manifest,
const std::vector<std::string>& sourceAbsPaths,
const std::string& destAbsPath);
// The verb: plan, gate on the verdict and the destination, digest, encode, stream.
ExportOutcome exportBank(const ReaSamplerSession& session, const ExportRequest& req);
} // namespace reasampler
+97
View File
@@ -0,0 +1,97 @@
// import_bank.cpp — see import_bank.h for the contract. The REAPER-facing half of the
// import: the project directory, the minted bank id, the birth records, and the one
// undo-batched persist. Every decision it makes is in import_landing / import_plan.
//
// main.cpp owns the API pointers; this TU gets them extern. DAW-verified, not unit tested.
#include "shell/package/import_bank.h"
#include <cstdint>
#include <ctime>
#include <string>
#include <vector>
#include "core/capture/capture_paths.h" // projectDirOfRpp
#include "shell/bank_ops/bank_ops.h" // persistBankOp — one bank op is one Ctrl-Z
#include "shell/persist/session.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_genGuid
#define REAPERAPI_WANT_guidToString
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
std::string activeProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return capture::projectDirOfRpp(std::string(buf.data()));
}
// The model mints no ids (it stays pure and deterministic), so the shell does — the
// same GUID pair bankOpCreate uses.
std::string mintBankId() {
GUID g{};
genGuid(&g);
char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract)
guidToString(&g, buf);
return std::string(buf);
}
void fillPlanCounts(ImportBankResult& out, const package::ImportPlan& plan) {
out.bankDisplayName = plan.bankDisplayName;
out.seedBankName = plan.seedBankName;
out.bankNameAdjusted = plan.bankNameAdjusted;
out.landedCount = plan.landCount;
out.collisionRenameCount = plan.collisionRenameCount;
out.sanitizeRenameCount = plan.sanitizeRenameCount;
out.collapsedCount = plan.collapseCount;
}
} // namespace
ImportBankResult importBankPackage(ReaSamplerSession& session,
const std::string& packageAbsPath) {
ImportBankResult out;
// A per-import disambiguator, the same shape capture and ingest file under.
const std::string uniqueTag =
std::to_string(static_cast<std::int64_t>(std::time(nullptr)));
LandedFileJournal journal;
const ImportLanding landing = landPackage(packageAbsPath, activeProjectDir(),
session.book(), uniqueTag, journal);
out.outcome = landing.outcome;
out.header = landing.header;
out.failedEntryName = landing.failedEntryName;
out.rollback = landing.rollback;
fillPlanCounts(out, landing.plan);
if (landing.outcome != ImportOutcome::Landed) return out;
const std::string bankId = mintBankId();
const bool applied = applyImportedBank(
session.book(), bankId, landing.plan,
[&session](const model::Sample& s) {
session.recordCreated(s, tracking::OriginKind::PackageImport);
});
if (!applied) {
out.outcome = ImportOutcome::IndexRejected;
out.rollback = journal.rollback();
return out;
}
out.bankId = bankId;
// Generation bump + persist ride inside one undo block, so a Ctrl-Z takes the whole
// import back out of the index. It does NOT un-write the files — the summary says so.
persistBankOp(session, "ReaSampler: import bank package", /*bumpGeneration=*/true);
// Only now: the files are referenced, so prune's self-cleanup carve-out no longer
// covers them (see package_rollback.h).
journal.markIndexCommitted();
return out;
}
} // namespace reasampler
+40
View File
@@ -0,0 +1,40 @@
#pragma once
// shell/package/import_bank — the promptless import verb: one package becomes one NEW
// bank in the live session, completely or not at all. No prompts, no message boxes, no
// picker — it reports and the action skin (shell/actions/package_import_action) speaks.
// The ledger gate is the skin's, because it must refuse BEFORE a file is even chosen.
#include <string>
#include "core/package/import_plan.h"
#include "shell/package/import_landing.h"
namespace reasampler {
class ReaSamplerSession;
struct ImportBankResult {
ImportOutcome outcome = ImportOutcome::Unreadable;
package::PackageHeader header; // TooNew names the writer's build from here
std::string bankId; // the minted id — meaningful only when outcome == Landed
std::string bankDisplayName; // the bank actually created
std::string seedBankName; // what the package asked to be called
bool bankNameAdjusted = false;
int landedCount = 0;
int collisionRenameCount = 0; // renamed: the package's own name was already taken
int sanitizeRenameCount = 0; // renamed: not spelled the way this tool spells a bank file
int collapsedCount = 0;
std::string failedEntryName;
RollbackResult rollback;
};
// Lands `packageAbsPath` as a new bank in `session`, in ONE undo point, bumping the
// bank generation so live instances reload. Places no timeline item. On any failure
// nothing remains on disk and the book is untouched.
ImportBankResult importBankPackage(ReaSamplerSession& session,
const std::string& packageAbsPath);
} // namespace reasampler
+149
View File
@@ -0,0 +1,149 @@
// import_landing.cpp — see import_landing.h for the contract. REAPER-free: standard
// filesystem only, so every property this file decides is unit-testable.
#include "shell/package/import_landing.h"
#include <cassert>
#include <cstdint>
#include <filesystem>
#include <system_error>
#include <utility>
#include <vector>
#include "core/capture/wav_codec.h" // hashBytes — the digest the manifest records
#include "core/package/bank_package.h"
#include "shell/package/package_io.h"
#include "shell/package/package_path.h"
namespace reasampler {
namespace fs = std::filesystem;
using package::EntryAction;
using package::PackageEntrySpan;
namespace {
ImportLanding refusal(ImportOutcome outcome, const package::PackageHeader& header) {
ImportLanding out;
out.outcome = outcome;
out.header = header;
return out;
}
// Reads the package head incrementally: requiredPrefixSize may grow its answer as
// fields arrive, so ask, read to the count, ask again. False means these bytes can
// never frame a package, or the file is shorter than its own header claims.
bool readPrefix(PackageFileReader& reader, std::vector<std::uint8_t>& prefix) {
for (;;) {
const auto need = package::requiredPrefixSize(prefix);
if (!need) return false;
if (prefix.size() >= *need) return true;
PayloadBuffer head = reader.readRange(0, *need);
if (head.size() != *need) return false;
prefix.assign(head.data(), head.data() + head.size());
}
}
} // namespace
ImportLanding landPackage(const std::string& packageAbsPath,
const std::string& projectDir,
const BankBook& destination,
const std::string& uniqueTag,
LandedFileJournal& journal) {
const package::PackageHeader noHeader;
if (projectDir.empty()) return refusal(ImportOutcome::NoProject, noHeader);
PackageFileReader reader(packageAbsPath);
if (!reader.ok()) return refusal(ImportOutcome::Unreadable, noHeader);
std::vector<std::uint8_t> prefix;
if (!readPrefix(reader, prefix)) return refusal(ImportOutcome::Malformed, noHeader);
const package::DecodedPackage dec = package::decodePackage(prefix, reader.fileSize());
if (dec.status == package::PackageReadability::TooNew)
return refusal(ImportOutcome::TooNew, dec.header);
if (dec.status != package::PackageReadability::Readable)
return refusal(ImportOutcome::Malformed, dec.header);
const std::string bankDir = package::bankFolderDir(projectDir);
ImportLanding out;
out.header = dec.header;
out.plan = package::planImport(dec.manifest, destination, projectDir,
listFolderFileNames(bankDir), uniqueTag);
// Integrity first, over EVERY declared entry — including one the plan collapses,
// since a package that fails its own digest is refused whole rather than partly
// trusted. Nothing is on disk yet, so a failure here needs no rollback.
for (std::size_t i = 0; i < dec.layout.size(); ++i) {
const PackageEntrySpan& span = dec.layout[i];
// The format refuses a zero-length entry on encode; one arriving anyway cannot
// be told from a failed read at this seam, so it is not well-formed input.
if (span.length == 0) return refusal(ImportOutcome::Malformed, dec.header);
PayloadBuffer payload = reader.readRange(span.offset, span.length);
if (payload.size() != span.length) {
out.outcome = ImportOutcome::IntegrityFailed;
out.failedEntryName = span.name;
return out;
}
if (capture::hashBytes(payload.data(), payload.size()) !=
dec.manifest.entries[i].byteHash) {
out.outcome = ImportOutcome::IntegrityFailed;
out.failedEntryName = span.name;
return out;
}
}
std::error_code ec;
fs::create_directories(utf8Path(bankDir), ec); // idempotent; the write reports failure
for (const package::PlannedEntry& e : out.plan.entries) {
if (e.action != EntryAction::Land) continue;
const PackageEntrySpan& span = dec.layout[e.manifestIndex];
PayloadBuffer payload = reader.readRange(span.offset, span.length);
if (payload.size() == span.length &&
journal.writeLandedFile(bankDir + "/" + e.destFileName, payload)) {
continue;
}
out.outcome = ImportOutcome::WriteFailed;
out.failedEntryName = e.destFileName;
out.rollback = journal.rollback();
return out;
}
out.outcome = ImportOutcome::Landed;
return out;
}
bool applyImportedBank(BankBook& book, const std::string& bankId,
const package::ImportPlan& plan, const RecordBirth& recordBirth) {
// An empty std::function throws std::bad_function_call on invoke; every real caller
// supplies one, so an empty one here is a caller bug, not a runtime condition to
// recover from — enforce the contract rather than let it surface as an uncaught
// exception out of an extension action.
assert(recordBirth && "applyImportedBank: RecordBirth must not be empty");
if (!book.createBank(bankId, plan.bankDisplayName)) return false;
BankModel* index = book.index(bankId);
for (const package::PlannedEntry& e : plan.entries) {
if (e.action != EntryAction::Land) continue;
const AddResult added = index->add(e.sample);
// planImport already deduped Land entries by hash against an empty destination
// bank (this same freshly-created one), so a Collapsed add here would mean the
// plan and the book disagree — that would silently undercount reportSuccess's
// landedCount rather than fail loudly.
assert(added == AddResult::Added && "planImport's Land entries must not collapse");
(void)added;
// Unconditional on the add's outcome: the file exists either way, and an
// unrecorded file is permanently unreclaimable.
recordBirth(e.sample);
}
book.bank(bankId)->slots = plan.slots;
book.reconcileSlots();
return true;
}
} // namespace reasampler
+68
View File
@@ -0,0 +1,68 @@
#pragma once
// shell/package/import_landing — the import's filesystem half and its index half,
// both REAPER-free so the all-or-nothing, integrity and birth-record properties are
// assertable without a DAW. The verb that drives them against a live session is
// import_bank; the REAPER-facing reporting is shell/actions/package_import_action.
#include <functional>
#include <string>
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/package/import_plan.h"
#include "core/package/package_format.h"
#include "shell/package/package_rollback.h"
namespace reasampler {
// How a landing ended. Every value but Landed means NOTHING is on disk and NO index was
// touched. NoProject/Unreadable/Malformed/TooNew refuse before a byte is written.
// IntegrityFailed also refuses before any write — the full-package digest verification
// runs to completion first (landPackage) — so it needs no rollback either. WriteFailed
// is the only outcome that actually wrote and then rolled back. IndexRejected is never
// returned by landPackage/this struct — it is import_bank's own outcome, minted after a
// successful landing when the book itself refuses the create.
enum class ImportOutcome {
Landed,
NoProject, // unsaved project: there is no bank folder to land into
Unreadable, // the package file could not be opened
Malformed, // not a well-formed RSBK: corrupt, truncated, or trailing garbage
TooNew, // minReaderVersion above this build's ladder
IntegrityFailed, // an entry's payload did not match its recorded digest; pre-write refusal
WriteFailed, // a write failed partway; the landed files were rolled back
IndexRejected, // never set here — see the comment above; import_bank's outcome only
};
struct ImportLanding {
ImportOutcome outcome = ImportOutcome::Unreadable;
// Meaningful from the moment the header parsed — a TooNew refusal names the
// writer's build, which is the only part of that message a user can act on.
package::PackageHeader header;
package::ImportPlan plan;
std::string failedEntryName; // IntegrityFailed / WriteFailed
RollbackResult rollback; // WriteFailed only — IntegrityFailed leaves it default
};
// Streams `packageAbsPath` into the project's bank folder: decode, plan, verify EVERY
// payload's digest, then land. Verification runs to completion before the first write,
// so a damaged package costs no rollback at all. Mutates no index and holds at most
// one payload at a time. `journal` is left armed on success — the caller applies the
// plan to the book and only then disarms it.
ImportLanding landPackage(const std::string& packageAbsPath,
const std::string& projectDir,
const BankBook& destination,
const std::string& uniqueTag,
LandedFileJournal& journal);
// Called for every landed file, in the same straight-line block as its bank add —
// core/tracking/CLAUDE.md's no-silent-gaps invariant, kept structural by passing the
// writer in rather than letting a caller add first and record later.
using RecordBirth = std::function<void(const model::Sample&)>;
// Adds the plan's landed entries to a NEW bank under `bankId`. False (no mutation)
// only if the book refuses the create — the name was minted free against this same
// book, so that means the book changed underneath the plan.
bool applyImportedBank(BankBook& book, const std::string& bankId,
const package::ImportPlan& plan, const RecordBirth& recordBirth);
} // namespace reasampler
+276
View File
@@ -0,0 +1,276 @@
// package_io.cpp — see package_io.h for the seam's contract. Non-throwing at the
// boundary about filesystem_error: every filesystem call uses the error_code form.
// Allocation can still throw bad_alloc — readRange's sanity ceiling exists to keep
// that surface small, not to remove it.
#include "shell/package/package_io.h"
#include <algorithm>
#include <atomic>
#include <limits>
#include <utility>
#include "shell/package/package_path.h"
#ifdef _WIN32
#include <fcntl.h>
#include <io.h>
#include <share.h>
#include <sys/stat.h>
#else
#include <cerrno>
#include <fcntl.h>
#include <unistd.h>
#endif
namespace reasampler {
namespace fs = std::filesystem;
namespace {
std::atomic<int> g_alivePayloads{0};
std::atomic<int> g_peakAlivePayloads{0};
}
// ---------------------------------------------------------------------------
// PayloadBuffer
PayloadBuffer::PayloadBuffer(std::vector<std::uint8_t> bytes)
: bytes_(std::move(bytes)), counted_(!bytes_.empty()) {
if (counted_) {
const int now = g_alivePayloads.fetch_add(1, std::memory_order_relaxed) + 1;
int peak = g_peakAlivePayloads.load(std::memory_order_relaxed);
while (now > peak && !g_peakAlivePayloads.compare_exchange_weak(
peak, now, std::memory_order_relaxed)) {
}
}
}
PayloadBuffer::~PayloadBuffer() { release(); }
PayloadBuffer::PayloadBuffer(PayloadBuffer&& other) noexcept
: bytes_(std::move(other.bytes_)), counted_(other.counted_) {
// The count transfers with the bytes — a move must never double-count.
other.bytes_.clear();
other.counted_ = false;
}
PayloadBuffer& PayloadBuffer::operator=(PayloadBuffer&& other) noexcept {
if (this != &other) {
release();
bytes_ = std::move(other.bytes_);
counted_ = other.counted_;
other.bytes_.clear();
other.counted_ = false;
}
return *this;
}
int PayloadBuffer::alive() { return g_alivePayloads.load(std::memory_order_relaxed); }
int PayloadBuffer::highWaterMark() { return g_peakAlivePayloads.load(std::memory_order_relaxed); }
void PayloadBuffer::release() {
if (counted_) g_alivePayloads.fetch_sub(1, std::memory_order_relaxed);
counted_ = false;
bytes_.clear();
}
// ---------------------------------------------------------------------------
// PackageFileWriter
PackageFileWriter::PackageFileWriter(const std::string& destAbsPath)
: destPath_(utf8Path(destAbsPath)), tempPath_(destPath_) {
tempPath_ += ".rsbanktmp"; // += concatenates; / would make it a child
out_.open(tempPath_, std::ios::binary | std::ios::trunc);
ok_ = static_cast<bool>(out_);
}
PackageFileWriter::~PackageFileWriter() {
if (!done_) abort();
}
bool PackageFileWriter::appendRaw(const std::uint8_t* data, std::size_t len) {
if (!ok_ || done_) return false;
if (len == 0) return true;
out_.write(reinterpret_cast<const char*>(data),
static_cast<std::streamsize>(len));
ok_ = static_cast<bool>(out_);
return ok_;
}
bool PackageFileWriter::appendPayload(const PayloadBuffer& payload) {
if (payload.empty()) {
ok_ = false; // the stream is now short of what the framing will claim
return false;
}
return appendRaw(payload.data(), payload.size());
}
bool PackageFileWriter::commit() {
if (done_) return false;
if (ok_) {
out_.flush();
ok_ = static_cast<bool>(out_);
}
out_.close();
if (!ok_) {
abort();
return false;
}
// rename() replaces the destination in one step (the mono-collapse precedent):
// prior contents survive until the replacement is known-complete, and a failed
// rename self-cleans the temp rather than littering it.
std::error_code ec;
fs::rename(tempPath_, destPath_, ec);
if (ec) {
fs::remove(tempPath_, ec);
done_ = true;
ok_ = false;
return false;
}
done_ = true;
return true;
}
void PackageFileWriter::abort() {
if (done_) return;
out_.close();
std::error_code ec;
fs::remove(tempPath_, ec);
done_ = true;
ok_ = false;
}
// ---------------------------------------------------------------------------
// PackageFileReader
PackageFileReader::PackageFileReader(const std::string& srcAbsPath) {
const fs::path path = utf8Path(srcAbsPath);
std::error_code ec;
const std::uintmax_t sz = fs::file_size(path, ec);
if (ec) return;
in_.open(path, std::ios::binary);
if (!in_) return;
size_ = static_cast<std::uint64_t>(sz);
ok_ = true;
}
PayloadBuffer PackageFileReader::readRange(std::uint64_t offset, std::uint64_t length) {
// Overflow-safe range check: length is capped by the real file size before any
// allocation happens, so a hostile offset/length pair cannot demand the moon.
// Also rejected here rather than truncated: a length that would not fit in
// size_t (possible on a 32-bit build, where streamsize below stays 64-bit and
// so would read past a truncated allocation) and a length past the sanity
// ceiling, which exists so a merely large-but-real file size can't still hand
// std::vector a multi-gigabyte demand.
//
// `length > size_` short-circuits before the two branches below ever see a real
// file, so neither is reachable without a genuine >4 GiB fixture — this guard
// ships unexercised by test_package_io.cpp, which covers past-the-end,
// starts-at-the-end, and zero-length only. The ordering (cheap size check first)
// is deliberate and correct; it is not reordered to make the branch testable.
constexpr std::uint64_t kMaxReadRangeBytes = std::uint64_t{4} << 30; // 4 GiB
if (!ok_ || length == 0 || length > size_ || offset > size_ - length ||
length > kMaxReadRangeBytes ||
length > static_cast<std::uint64_t>(std::numeric_limits<std::size_t>::max())) {
return PayloadBuffer{};
}
in_.clear(); // a prior failed read must not poison this one
in_.seekg(static_cast<std::streamoff>(offset));
if (!in_) return PayloadBuffer{};
std::vector<std::uint8_t> bytes(static_cast<std::size_t>(length));
in_.read(reinterpret_cast<char*>(bytes.data()),
static_cast<std::streamsize>(length));
if (static_cast<std::uint64_t>(in_.gcount()) != length) return PayloadBuffer{};
return PayloadBuffer(std::move(bytes));
}
// ---------------------------------------------------------------------------
// Deliberately NOT core/util/file_bytes: that loader takes an unconverted narrow path
// (see package_path.h), and this seam's own reader already goes through utf8Path.
PayloadBuffer readFilePayload(const std::string& absPath) {
PackageFileReader reader(absPath);
return reader.readRange(0, reader.fileSize());
}
FileStatus fileStatus(const std::string& absPath) {
const fs::path path = utf8Path(absPath);
std::error_code ec;
const fs::file_status st = fs::status(path, ec);
// status() reports not_found through the type AND sets ec, so the type is the
// discriminator; an ec with any other type is a real access failure.
if (st.type() == fs::file_type::not_found) return FileStatus::Absent;
if (ec || !fs::is_regular_file(st)) return FileStatus::Unreadable;
std::ifstream probe(path, std::ios::binary);
return probe ? FileStatus::Present : FileStatus::Unreadable;
}
bool writeFileExclusive(const std::string& absPath, const PayloadBuffer& payload) {
if (payload.empty()) return false;
const fs::path path = utf8Path(absPath);
int fd = -1;
#ifdef _WIN32
if (_wsopen_s(&fd, path.wstring().c_str(),
_O_WRONLY | _O_CREAT | _O_EXCL | _O_BINARY, _SH_DENYNO,
_S_IREAD | _S_IWRITE) != 0) {
return false;
}
#else
fd = ::open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644);
#endif
if (fd < 0) return false;
bool ok = true;
std::size_t written = 0;
while (written < payload.size()) {
// Chunked because the Windows _write count is an unsigned int, not size_t.
const std::size_t chunk =
std::min<std::size_t>(payload.size() - written, 1u << 20);
#ifdef _WIN32
const int n = _write(fd, payload.data() + written,
static_cast<unsigned int>(chunk));
#else
const ssize_t n = ::write(fd, payload.data() + written, chunk);
if (n < 0 && errno == EINTR) continue; // a signal on the UI thread isn't a failure
#endif
if (n <= 0) {
ok = false;
break;
}
written += static_cast<std::size_t>(n);
}
#ifdef _WIN32
_close(fd);
#else
::close(fd);
#endif
if (!ok) {
// Self-cleanup, not deletion authority: this call created the file moments
// ago and nothing has ever referenced it (prune_fs.cpp's carve-out).
std::error_code ec;
fs::remove(path, ec);
}
return ok;
}
std::vector<std::string> listFolderFileNames(const std::string& dirAbsPath) {
std::vector<std::string> names;
std::error_code ec;
// Manual iterator form (it.increment(ec)) keeps the loop non-throwing on a
// mid-iteration failure, matching prune_fs's enumerate.
fs::directory_iterator it(utf8Path(dirAbsPath), ec);
for (; !ec && it != fs::directory_iterator{}; it.increment(ec)) {
const auto& entry = *it;
std::error_code reg_ec;
if (!entry.is_regular_file(reg_ec)) continue; // skip subdirs / specials
names.push_back(pathToUtf8(entry.path().filename())); // never .string(): ANSI
}
std::sort(names.begin(), names.end());
return names;
}
} // namespace reasampler
+132
View File
@@ -0,0 +1,132 @@
// shell/package/package_io — every filesystem act the export/import verbs need:
// streaming package read/write, whole-file payload read, folder listing, file status,
// and the exclusive create that lands one bank file. Bytes only — what a package
// contains is core/package's business. Blocking I/O: UI-thread actions only, never
// the audio thread.
#pragma once
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace reasampler {
// One entry's payload. Move-only, because a copy would silently double the bytes the
// seam promises to hold at most one of; alive() is the counter that makes that
// promise assertable instead of aspirational.
class PayloadBuffer {
public:
PayloadBuffer() = default;
explicit PayloadBuffer(std::vector<std::uint8_t> bytes);
~PayloadBuffer();
PayloadBuffer(PayloadBuffer&& other) noexcept;
PayloadBuffer& operator=(PayloadBuffer&& other) noexcept;
PayloadBuffer(const PayloadBuffer&) = delete;
PayloadBuffer& operator=(const PayloadBuffer&) = delete;
const std::uint8_t* data() const { return bytes_.data(); }
std::size_t size() const { return bytes_.size(); }
bool empty() const { return bytes_.empty(); }
// Buffers currently holding at least one byte, process-wide.
static int alive();
// The largest alive() has ever been, process-wide. A point-in-time alive() == 0
// check after a call returns cannot fail on a whole-package-in-memory shape that
// allocated N buffers and freed them all one at a time — highWaterMark() can,
// since it is never reset.
static int highWaterMark();
private:
void release();
std::vector<std::uint8_t> bytes_;
bool counted_ = false;
};
// Streaming atomic writer; paths cross this seam as UTF-8 narrow strings and are held
// as fs::path internally. The temp sibling is created in the DESTINATION's own
// directory so commit()'s rename never crosses a volume — a cross-device rename
// degrades to a copy and stops being atomic. commit() REPLACES an existing
// destination (the deliberate asymmetry against LandedFileJournal; see CLAUDE.md).
class PackageFileWriter {
public:
explicit PackageFileWriter(const std::string& destAbsPath);
~PackageFileWriter();
PackageFileWriter(const PackageFileWriter&) = delete;
PackageFileWriter& operator=(const PackageFileWriter&) = delete;
bool ok() const { return ok_; }
// Framing/header bytes. False on a failed or already-finished writer. A zero
// length is accepted — framing has legitimate zero-length edges.
bool appendRaw(const std::uint8_t* data, std::size_t len);
// One entry's bytes. Also false — and the writer poisoned — on an EMPTY payload:
// empty is this seam's one "nothing to work with" signal, so accepting it would
// let a verb commit a package whose framing claims bytes nobody wrote.
bool appendPayload(const PayloadBuffer& payload);
// Flush, close, rename over the destination. False (and self-cleaning: the temp
// is removed, the destination untouched) on any failure or on a second call.
bool commit();
// Close and remove the temp; the destination is never touched. Idempotent.
void abort();
const std::filesystem::path& destPath() const { return destPath_; }
const std::filesystem::path& tempPath() const { return tempPath_; }
private:
std::filesystem::path destPath_;
std::filesystem::path tempPath_;
std::ofstream out_;
bool ok_ = false;
bool done_ = false;
};
// Seek-and-read reader: exactly one payload is materialized per readRange call, and
// there is deliberately no read-whole-file entry point. Empty buffer on ANY failure —
// unopenable file, zero length, out of range, short read — so the caller has one
// "nothing to work with" branch. Use fileStatus() when the two must be told apart.
class PackageFileReader {
public:
explicit PackageFileReader(const std::string& srcAbsPath);
bool ok() const { return ok_; }
std::uint64_t fileSize() const { return size_; }
// Bytes [offset, offset+length). Range-checked against the real file size, so a
// hostile layout can never demand an allocation past the file's end, and capped
// against a 4 GiB sanity ceiling so a merely large-but-real file can't still
// force a multi-gigabyte allocation out of one call.
PayloadBuffer readRange(std::uint64_t offset, std::uint64_t length);
private:
std::ifstream in_;
std::uint64_t size_ = 0;
bool ok_ = false;
};
// One source file read whole as one entry's payload — a bank file IS the streaming
// unit. Empty on any failure, per PackageFileReader — including a source file over
// readRange's 4 GiB ceiling, which reads as empty exactly like an unreadable file;
// fileStatus() cannot tell the two apart either, since it only checks openability.
PayloadBuffer readFilePayload(const std::string& absPath);
// Export must tell a missing indexed file from an unreadable one in its refusal
// message; readFilePayload deliberately cannot, since both fail to an empty buffer.
enum class FileStatus { Present, Absent, Unreadable };
FileStatus fileStatus(const std::string& absPath);
// Creates absPath and writes the payload, failing if ANYTHING already occupies the
// path. The create IS the existence check (O_EXCL / CREATE_NEW), so nothing can slip
// in between: an exists()-then-write pair would let a file created in that window be
// overwritten and then deleted by a rollback that believes it wrote it. Refuses an
// empty payload, and removes its own partial file on a mid-write failure. Not
// temp+rename — an exclusive rename has no portable spelling, and the debris a crash
// leaves here is unrecorded and unindexed either way.
bool writeFileExclusive(const std::string& absPath, const PayloadBuffer& payload);
// Bare file names (regular files only, never a path) in dirAbsPath, UTF-8, sorted so
// callers see a deterministic order; empty on a missing or unreadable folder.
std::vector<std::string> listFolderFileNames(const std::string& dirAbsPath);
} // namespace reasampler
+26
View File
@@ -0,0 +1,26 @@
// shell/package/package_path — the ONE narrow-string <-> fs::path conversion pair for
// this seam. std::filesystem decodes a narrow path through the RUNTIME ANSI code page
// on Windows (measured: GetACP() == 1252 here), never UTF-8, so a bare
// fs::path(std::string) turns every non-ASCII path this repo's UTF-8 convention
// produces into mojibake. u8path is the C++17 spelling; it is deprecated in C++20, so
// a standard bump replaces both bodies here rather than at every call site.
#pragma once
#include <filesystem>
#include <string>
namespace reasampler {
inline std::filesystem::path utf8Path(const std::string& utf8) {
return std::filesystem::u8path(utf8);
}
// u8string() returns std::u8string in C++20 — this is the one place that narrows it
// back to std::string, so a standard bump only widens this one body.
inline std::string pathToUtf8(const std::filesystem::path& path) {
const auto u8 = path.u8string();
return std::string(u8.begin(), u8.end());
}
} // namespace reasampler
+66
View File
@@ -0,0 +1,66 @@
// package_pickers.cpp — see package_pickers.h. GetUserFileName cannot be null here:
// main.cpp defines REAPERAPI_IMPLEMENT without REAPERAPI_MINIMAL, so the generated
// resolver walks the FULL table, and it aborts the extension load if any single name
// fails to resolve. A fallback picker would be unreachable code.
#include "shell/package/package_pickers.h"
#include <algorithm>
#include <cctype>
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_GetUserFileName
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
// GetUserFileName's documented pair format: label|pattern|label|pattern.
const char kExtList[] =
"ReaSampler bank package (*.rsbank)|*.rsbank|All files (*.*)|*.*";
bool runPicker(int mode, const char* caption, const char* initial,
std::string& outAbsPath) {
outAbsPath.clear();
char buf[4096];
buf[0] = '\0';
if (!GetUserFileName(mode, caption, initial, kExtList, buf,
static_cast<int>(sizeof(buf)))) {
return false;
}
outAbsPath = buf;
return !outAbsPath.empty();
}
bool hasCaseInsensitiveSuffix(const std::string& path, const std::string& suffix) {
if (path.size() < suffix.size()) return false;
return std::equal(suffix.rbegin(), suffix.rend(), path.rbegin(),
[](unsigned char a, unsigned char b) {
return std::tolower(a) == std::tolower(b);
});
}
} // namespace
bool pickPackageForImport(std::string& outAbsPath) {
return runPicker(1, "Import bank package", "", outAbsPath);
}
bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPath,
bool* outAppended) {
// [verify — DAW] GetUserFileName takes no owner window, so the dialog's parenting
// is REAPER's to do; the previous Win32 path passed GetMainHwnd() explicitly.
if (!runPicker(0, "Export bank package", suggestedPath.c_str(), outAbsPath)) {
return false;
}
// GetUserFileName has no lpstrDefExt equivalent (the old Win32 picker's
// ofn.lpstrDefExt = L"rsbank"); whether mode 0 appends one itself from
// kExtList is [verify — DAW], so append it ourselves whenever it's missing.
const bool appended = !hasCaseInsensitiveSuffix(outAbsPath, ".rsbank");
if (appended) outAbsPath += ".rsbank";
if (outAppended) *outAppended = appended;
return true;
}
} // namespace reasampler
+29
View File
@@ -0,0 +1,29 @@
// shell/package/package_pickers — the two package file pickers, both on REAPER's own
// GetUserFileName (mode 1 = existing file, mode 0 = new file). No native/platform
// split: the SDK's save mode is not optional on any build that can load this
// extension. Paths in and out are UTF-8, matching this tree's established practice
// for narrow strings crossing the REAPER API (see instrument_drop_win.cpp's
// path.u8string() to TrackFX_SetPreset, or prune_fs.cpp's CP_UTF8 conversion) — the
// SDK header itself never says "UTF-8".
#pragma once
#include <string>
namespace reasampler {
// True with outAbsPath set iff the user chose a file.
bool pickPackageForImport(std::string& outAbsPath);
// suggestedPath is a bare file name ("MyBank.rsbank") or a full path — a full one
// also seeds the dialog's starting directory, which is how a caller keeps the picker
// off REAPER's process working directory. True with outAbsPath set iff the user chose
// a destination; the dialog's own overwrite confirm has already run by then, against
// the path the user actually chose — NOT necessarily outAbsPath, if the `.rsbank`
// re-append below fires. outAppended, when non-null, is set to whether it fired: the
// caller's signal that its own overwrite consent may not cover the returned path
// (see this directory's CLAUDE.md).
bool pickPackageSavePath(const std::string& suggestedPath, std::string& outAbsPath,
bool* outAppended = nullptr);
} // namespace reasampler
+51
View File
@@ -0,0 +1,51 @@
// package_rollback.cpp — see package_rollback.h. The rollback delete below runs under
// the ONE carve-out from prune's exclusive file-deletion authority, stated at
// src/shell/persist/prune_fs.cpp:5-11. That discriminator has two clauses and this
// journal makes only the FIRST structural: "did this call create it" is guaranteed by
// recording exclusively-created paths, but "did anything ever reference it" is a
// claim about the caller's ordering — hence markIndexCommitted(), which the import
// verb must fire at the index commit so a later rollback() refuses instead of
// deleting indexed files.
#include "shell/package/package_rollback.h"
#include <filesystem>
#include "shell/package/package_path.h"
namespace reasampler {
namespace fs = std::filesystem;
bool LandedFileJournal::writeLandedFile(const std::string& destPath,
const PayloadBuffer& payload) {
if (indexCommitted_) return false;
std::error_code ec;
const fs::path resolved = fs::absolute(utf8Path(destPath), ec);
if (ec) return false;
const std::string absPath = pathToUtf8(resolved);
if (!writeFileExclusive(absPath, payload)) return false;
paths_.push_back(absPath);
return true;
}
RollbackResult LandedFileJournal::rollback() {
RollbackResult result;
if (indexCommitted_) {
result.refused = true;
return result;
}
for (const std::string& path : paths_) {
std::error_code ec;
const bool removed = fs::remove(utf8Path(path), ec);
if (removed) ++result.deletedCount;
else if (ec) ++result.failedCount;
else ++result.alreadyAbsentCount; // no error, nothing there
}
paths_.clear();
return result;
}
} // namespace reasampler
+59
View File
@@ -0,0 +1,59 @@
// shell/package/package_rollback — the files ONE import call has landed, as a
// journal: writes record themselves on success, and rollback() deletes exactly what
// is recorded. The deletion carve-out this satisfies, and the half of it the caller
// still owns, are at package_rollback.cpp's header.
#pragma once
#include <string>
#include <vector>
#include "shell/package/package_io.h"
namespace reasampler {
struct RollbackResult {
int deletedCount = 0;
int alreadyAbsentCount = 0; // vanished between land and rollback — not a failure
int failedCount = 0; // locked / permission — recorded, never thrown
bool refused = false; // markIndexCommitted() ran: nothing was deleted
};
// Destroying an armed (uncommitted, un-rolled-back) journal is NOT an implicit
// rollback — the caller must call rollback() itself on the failure path it wants
// to undo. That's the fail-safe direction: a journal dropped by an unrelated early
// return leaves the landed files in place rather than silently deleting them.
class LandedFileJournal {
public:
// Lands one payload at destPath through the exclusive create (which refuses an
// occupied path outright — a bank-folder file is never overwritten, and collision
// handling is the import plan's job upstream) and records it on success. An empty
// payload is refused, per writeFileExclusive. Relative paths are resolved against
// the process CWD before the write, so the journal's record is always absolute
// and a later CWD change cannot re-aim the delete. Refused once
// markIndexCommitted() has run.
bool writeLandedFile(const std::string& destPath, const PayloadBuffer& payload);
// Disarms the journal: the index mutation these files back is committed, so they
// are now referenced bytes and the carve-out no longer covers them. This is the
// half of prune's discriminator the journal cannot make structural on its own —
// the import verb MUST call this only AFTER the index write has returned success.
// Calling it before, then having that write fail, strands the landed files with
// no index entry and a journal that now refuses to roll them back.
void markIndexCommitted() { indexCommitted_ = true; }
bool indexCommitted() const { return indexCommitted_; }
// Deletes exactly the recorded files and clears the journal, so a second call is
// a no-op. Hard unlink, not trash: nothing ever referenced these bytes. Refuses
// (deleting nothing, keeping the record) once markIndexCommitted() has run.
RollbackResult rollback();
const std::vector<std::string>& landedPaths() const { return paths_; }
bool empty() const { return paths_.empty(); }
private:
std::vector<std::string> paths_;
bool indexCommitted_ = false;
};
} // namespace reasampler
+1 -1
View File
@@ -48,7 +48,7 @@ live in `shell/bank_ops`, a sibling directory, not here.
## Modules
- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into eight TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU.
- `panel_window` — SWELL dialog lifecycle + dialog proc + drop-target opt-in.
- `panel_window` — SWELL dialog lifecycle + dialog proc + drop-target opt-in. The drop splits by extension: a `.rsbank` is a whole bank and routes to the package-import action (one NEW bank each), everything else keeps the audio-ingest route.
- `panel_layout` — toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read).
- `panel_render` — the WM_PAINT draw.
- `panel_input` — click/wheel/keyboard routing + the new-content auto-tag timer.
+20 -1
View File
@@ -16,6 +16,8 @@
#include "shell/panel/panel_state.h"
#include "shell/panel/panel_bank_ops.h"
#include "shell/actions/package_export_action.h" // doBankPackageExport — the export skin
#include "shell/actions/package_import_action.h" // doImportBankPackage — the menu's import row
#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs
#include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate
@@ -241,7 +243,9 @@ enum : unsigned int {
kMenuDelete,
kMenuEvacuate,
kMenuCreate,
kMenuExport, // export this bank as a .rsbank package
kMenuRemove, // remove selected sample(s) from the source bank
kMenuImportPackage, // land a .rsbank as a NEW bank (never merges into this one)
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
};
@@ -249,7 +253,7 @@ enum : unsigned int {
} // namespace
// Shows the right-click context menu for a named-bank TAB: activate / rename / delete
// / evacuate that bank, plus a create entry. Drives the id-keyed ops.
// / evacuate / export that bank, plus a create entry. Drives the id-keyed ops.
void showTabMenu(int screenX, int screenY, const std::string& bankId) {
if (!book()) return;
const Bank* bk = book()->bank(bankId);
@@ -265,8 +269,10 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
menuAppend(menu, kMenuRename, "Rename...");
menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty);
menuAppend(menu, kMenuDelete, "Delete...");
menuAppend(menu, kMenuExport, "Export as package...");
menuSeparator(menu);
menuAppend(menu, kMenuCreate, "New bank...");
menuAppend(menu, kMenuImportPackage, "Import bank package...");
const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
g_panel.hwnd, nullptr);
@@ -277,7 +283,20 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
case kMenuRename: doRenameBank(bankId); break;
case kMenuEvacuate: doEvacuateBank(bankId); break;
case kMenuDelete: doDeleteBank(bankId); break;
case kMenuExport: doBankPackageExport(*g_panel.session, bankId); break;
case kMenuCreate: doCreateBank(); break;
// Always a NEW bank, never a merge into the right-clicked one — the row sits
// here because this is the panel's bank menu, not because it targets this bank.
case kMenuImportPackage:
if (g_panel.session) {
const std::string id = doImportBankPackage(*g_panel.session);
if (!id.empty()) { // landed — show the freshly-imported bank
g_panel.shownBankId = id;
g_panel.focusedRegion = Region::Banks;
invalidatePanel();
}
}
break;
default: break;
}
}
+10 -1
View File
@@ -5,6 +5,7 @@
// Compiled into the reaper_reasampler MODULE, without REAPERAPI_IMPLEMENT (main.cpp
// owns the API pointers). DAW-verified, not unit-tested.
#include <algorithm>
#include <map>
#include <set>
#include <string>
@@ -163,11 +164,19 @@ bool detectNewContent() {
std::map<std::string, std::vector<std::string>> trackItemGuids;
enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids);
const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
std::vector<std::string> added = g_panel.contentBaseline.observe(live);
if (added.empty()) return false; // first poll after open, or nothing new this tick
ViewModeModel& model = g_panel.session->view();
// An explicit tag wins: this detector classifies content the USER made, not
// content the tool made and already classified.
added.erase(std::remove_if(added.begin(), added.end(),
[&](const std::string& g) {
return model.membership().query(g) != nullptr;
}),
added.end());
// Which of `added` are items (the manual-lane map keys every item; track GUIDs never
// appear there). Used below to exclude sibling new items from a track's PRE-EXISTING
// mode set — a drop plus its own new siblings must not count each other as prior.
+37 -3
View File
@@ -15,6 +15,10 @@
#include "shell/panel/draw_kit.h"
#include "shell/actions/ingest.h"
#include "shell/actions/package_import_action.h"
#include "core/package/import_plan.h"
#include "core/version/app_version.h"
#include "shell/persist/session.h" // ReaSamplerSession::ledgerStatus() — panel_state.h only forward-declares it
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
@@ -28,6 +32,7 @@
#define REAPERAPI_WANT_DockWindowActivate
#define REAPERAPI_WANT_DockWindowRemove
#define REAPERAPI_WANT_GetMainHwnd
#define REAPERAPI_WANT_ShowConsoleMsg
#include "reaper_plugin_functions.h"
// main.cpp owns the module instance handle.
@@ -40,12 +45,24 @@ PanelState g_panel;
namespace {
bool isPackagePath(const std::string& path) {
static const std::string kExt = ".rsbank";
if (path.size() <= kExt.size()) return false;
std::string tail = path.substr(path.size() - kExt.size());
for (char& c : tail)
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
return tail == kExt;
}
// DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; each path is then
// queried by index (length first, excludes NUL, then a sized buffer). DragFinish
// always frees the shell-allocated drop buffer. Multi-file drop imports all into
// the active bank (bank-fill only — no assignment to any live instance).
// always frees the shell-allocated drop buffer. A .rsbank is a whole bank, not audio,
// so it routes to the import verb (one new bank each); everything else keeps the
// existing ingest route — multi-file drop imports all into the active bank (bank-fill
// only, no assignment to any live instance).
void handleDropFiles(HDROP hDrop) {
std::vector<std::string> paths;
std::vector<std::string> packages;
const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0);
paths.reserve(count);
for (UINT i = 0; i < count; ++i) {
@@ -54,9 +71,26 @@ void handleDropFiles(HDROP hDrop) {
std::vector<char> buf(static_cast<std::size_t>(len) + 1, '\0');
DragQueryFile(hDrop, i, buf.data(), static_cast<UINT>(buf.size()));
std::string p(buf.data());
if (!p.empty()) paths.push_back(std::move(p));
if (p.empty()) continue;
if (isPackagePath(p)) packages.push_back(std::move(p));
else paths.push_back(std::move(p));
}
DragFinish(hDrop);
if (g_panel.session && !packages.empty()) {
// One refusal block for the whole drop, not one per dropped .rsbank: the gate
// decision is the same for all N (session state does not change mid-drop), so
// checking it here first avoids doImportBankPackageFile's own per-file gate
// check printing the identical console block N times.
const package::LedgerRefusal refusal =
package::importLedgerRefusal(g_panel.session->ledgerStatus());
if (refusal != package::LedgerRefusal::None) {
ShowConsoleMsg(
package::ledgerRefusalMessage(refusal, version::extStateNamespace()).c_str());
} else {
for (const std::string& pkg : packages)
doImportBankPackageFile(*g_panel.session, pkg);
}
}
if (!paths.empty()) ingestDroppedFiles(paths);
}
+13
View File
@@ -94,6 +94,19 @@ public:
// treatment.
void recordCreated(const model::Sample& sample, tracking::OriginKind kind);
// Whether a birth record can be written at all this session — the status WITHOUT
// the records. That is not a hole in the pairing rule above: the rule exists so an
// absent record is never read as a definite answer, and this exposes strictly less
// than the pair. The package import gates on it before it opens a file picker.
//
// A tradeoff, not the only route: `pruneDryRun()` already exposes the same degraded
// pair via `PruneReport::ledgerUnreadable`/`ledgerFutureVersion`, with no new
// accessor needed. Rejected because that route is genuinely worse for a gate: it
// drags a full bank-folder enumeration and every live instance's FX scan onto a
// check that only needs to know "can I write a record", and it shapes an import
// decision as an answer borrowed from prune's report rather than the session's own.
tracking::LedgerStatus ledgerStatus() const { return trackingStatus_; }
// The version that last wrote the active project: PreVersioning (no
// stamp), Unknown (malformed), or Stamped.
const version::WritingVersion& writingVersion() const { return writingVersion_; }
+13 -1
View File
@@ -34,7 +34,10 @@ decide membership or mode rules.
value BEFORE parking; on toggle-back restore FROM the snapshot, never to a
hardcoded "on." Round-trip (snapshot → park → restore) returns every driven flag
to its captured value — the phase's trust anchor, the analog of the capture null
test.
test. Per-FX offline is snapshotted WITH each FX's identity (`TrackFX_GetFXGUID`)
and restored through `core/view/fx_offline`, so a chain reordered while the track
was parked cannot land one plugin's state on another; an FX gone at restore time
is dropped and reported to the console, never restored onto its old slot.
- **GUID-keyed, reorder-safe.** Membership/snapshot keys on track GUID
(`GetTrackGUID`), never track index; tolerates unknown/stale GUIDs (pruned on
reconcile).
@@ -100,3 +103,12 @@ applies the resulting lane state to live tracks.
under whatever mode id is active at that point, not the one the user undid back
to. Pre-existing: `snapshots_` already carries this same model-vs-undo split;
the solo cache inherits it rather than introducing it. Not fixed here.
- `fx_offline`'s identity keying (`TrackFX_GetFXGUID`) assumes the GUID stays
attached to its plugin across a chain mutation while parked. That is
`[verify — DAW]` (see `fxGuidString` in `view.cpp`) and SWS issue #802 is a
known reason it might not hold: `SNM_MoveOrRemoveTrackFX` reportedly leaves
the FXID lines behind on reorder rather than moving them with the plugin. If
confirmed, an SWS-driven reorder of a parked track's chain — not a native
drag-reorder — can produce wrong-plugin restores or mass drops through
`resolveFxRestore`. Do not design around this pre-emptively; if native
reorder is clean (the likely case), only the SWS path degrades.
+71 -20
View File
@@ -14,6 +14,7 @@
#include <vector>
#include "shell/capture/item_read.h"
#include "core/view/fx_offline.h"
#include "core/view/lane_keys.h"
#include "core/view/solo_cache.h"
#include "shell/capture/track_guid.h"
@@ -27,9 +28,12 @@
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
#define REAPERAPI_WANT_SetMediaTrackInfo_Value
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_TrackFX_GetCount
#define REAPERAPI_WANT_TrackFX_GetFXGUID
#define REAPERAPI_WANT_TrackFX_GetOffline
#define REAPERAPI_WANT_TrackFX_SetOffline
#define REAPERAPI_WANT_guidToString
#define REAPERAPI_WANT_Undo_BeginBlock2
#define REAPERAPI_WANT_Undo_EndBlock2
#define REAPERAPI_WANT_TrackList_AdjustWindows
@@ -123,6 +127,40 @@ MediaTrack* resolve(const std::vector<std::pair<std::string, MediaTrack*>>& hand
return nullptr; // stale/deleted GUID — pruned by being skipped
}
// The FX's own durable identity, braced exactly like the track GUID keys. Empty
// when REAPER reports none — an FX we cannot name is one we cannot restore, and
// fx_offline treats it that way rather than guessing at its slot. Lifetime is
// settled: the string copy is taken immediately and the GUID* is never held
// past this call (reaper_plugin_functions.h:7348 documents no null contract for
// TrackFX_GetFXGUID; treating null as "no identity" is the safe read).
//
// [verify — DAW] STABILITY across a chain mutation is not settled the same way:
// confirm the GUID for one FX instance survives a native drag-reorder, an SWS
// move (SNM_MoveOrRemoveTrackFX — SWS issue #802 reports the FXID lines do not
// follow the plugin after that call, i.e. wrong-plugin restores or mass drops
// through fx_offline on that path specifically), a save/reload round trip, and
// two live instances of one plugin type staying distinguishable. See
// src/shell/view/CLAUDE.md's Gotchas for the SWS-path risk this leaves open.
std::string fxGuidString(MediaTrack* tr, int fx) {
GUID* g = TrackFX_GetFXGUID(tr, fx);
if (!g) return {};
char buf[64] = {0}; // guidToString needs a >=64-char destination (SDK contract)
guidToString(g, buf);
return std::string(buf);
}
// The chain as it stands now: identity by current slot. Snapshot, park and
// restore all address FX through this one plain 0..TrackFX_GetCount-1
// enumeration — never the 0x1000000/0x2000000 input-FX or container forms — so
// whatever it covers, all three cover identically.
std::vector<std::string> liveFxGuids(MediaTrack* tr) {
const int fxCount = TrackFX_GetCount(tr);
std::vector<std::string> guids;
guids.reserve(static_cast<std::size_t>(fxCount));
for (int fx = 0; fx < fxCount; ++fx) guids.push_back(fxGuidString(tr, fx));
return guids;
}
// Captures prior driven-flag state before parking. Never reads B_MUTE/I_SOLO;
// ints preserve whatever REAPER reported (TrackSnapshot's defensive contract).
TrackSnapshot snapshotTrack(MediaTrack* tr) {
@@ -132,12 +170,13 @@ TrackSnapshot snapshotTrack(MediaTrack* tr) {
snap.mainSend = static_cast<int>(GetMediaTrackInfo_Value(tr, "B_MAINSEND"));
snap.fxEnable = static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FXEN"));
int fxCount = TrackFX_GetCount(tr);
snap.fxOffline.reserve(static_cast<std::size_t>(fxCount));
for (int fx = 0; fx < fxCount; ++fx) {
snap.fxOffline.push_back(TrackFX_GetOffline(tr, fx) ? 1 : 0);
const std::vector<std::string> guids = liveFxGuids(tr);
snap.fxOffline.reserve(guids.size());
for (std::size_t fx = 0; fx < guids.size(); ++fx) {
snap.fxOffline.push_back(
FxOfflineState{guids[fx], TrackFX_GetOffline(tr, static_cast<int>(fx)) ? 1 : 0});
}
return snap;
return snap; // fxKeying stays Identity — a live capture always knows the chain
}
void applyFlags(MediaTrack* tr, const std::vector<TrackFlagOp>& flags) {
@@ -155,20 +194,13 @@ void parkFxOffline(MediaTrack* tr) {
}
}
// Restores per-FX offline from the snapshot verbatim — each slot back to its
// captured value, never a blanket "online" — bounds-checked against the live
// FX count (prune-safe if the chain changed while parked).
//
// HAZARD (open, tracked in docs/TODO.md): this remaps by slot INDEX, not
// plugin identity. If the FX chain reshuffled while parked, snapshot slot k
// restores onto whatever plugin now occupies slot k. Accepted for now;
// identity-based reconciliation is future hardening.
void restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
int fxCount = TrackFX_GetCount(tr);
for (const FxOfflineOp& op : fxOffline) {
if (op.fxIndex < 0 || op.fxIndex >= fxCount) continue;
TrackFX_SetOffline(tr, op.fxIndex, op.offline);
}
// Restores per-FX offline from the snapshot verbatim — never a blanket "online".
// Which live FX each captured state belongs to is resolveFxRestore's call, and
// what it could not place comes back for the caller to report.
FxRestoreDrops restoreFxOffline(MediaTrack* tr, const std::vector<FxOfflineOp>& fxOffline) {
const FxRestoreResolution res = resolveFxRestore(fxOffline, liveFxGuids(tr));
for (const FxOfflineWrite& w : res.writes) TrackFX_SetOffline(tr, w.fxIndex, w.offline);
return res.drops;
}
// Managed-lane application: the pure planner keys LanePlayOps by the lane's
@@ -451,6 +483,8 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
}
// RESTORE: apply verbatim, then drop the consumed snapshot.
FxRestoreDrops fxDrops;
int fxDropTracks = 0;
for (const TrackPlan& tp : plan.restore) {
if (tp.flags.empty()) continue;
const std::string& guid = tp.flags.front().guid;
@@ -458,10 +492,27 @@ bool applyMode(ViewModeModel& model, const std::string& targetModeId, ReaProject
if (!tr) continue; // stale GUID — prune
applyFlags(tr, tp.flags);
restoreFxOffline(tr, tp.fxOffline);
const FxRestoreDrops drops = restoreFxOffline(tr, tp.fxOffline);
if (drops.total() > 0) {
fxDrops.add(drops);
++fxDropTracks;
}
model.clearSnapshot(guid);
}
// Captured FX state that could not be applied is REPORTED. Silence here would
// read to the user as "restore worked" while an FX sat at whatever state the
// park left it in. Sent with the "!SHOW:" prefix (reaper_plugin_functions.h:6536)
// so it never force-opens the console window: applyMode's reapply path also
// runs unattended on project load (see the reconcile comment above), and this
// one call site can't tell that case apart from an interactive toggle/tag-edit
// reapply — both call in with target == active — so splitting loud-on-toggle
// from quiet-on-load would need a flag threaded from every caller, several of
// which are outside this change. Quiet-always is the safe default: the message
// still lands in the console for whoever opens it, on every path.
const std::string fxDropMsg = describeFxRestoreDrops(fxDrops, fxDropTracks);
if (!fxDropMsg.empty()) ShowConsoleMsg(("!SHOW:" + fxDropMsg).c_str());
// MANAGED LANES: drive C_LANEPLAYS so the active mode's lane plays+shows
// and every other managed lane is silenced+hidden. Empty for a D1-only
// project, leaving that behavior byte-identical.
+118
View File
@@ -0,0 +1,118 @@
# The frozen `.rsbank` compatibility corpus
Real RSBK bytes, committed. `tests/test_package_compat.cpp` decodes them;
`tests/test_package_round_trip.cpp` drives them through the import and export verbs.
## THE RULE: this corpus is append-only
**No file here is ever regenerated or edited.** When a future format version ships, add
its fixture beside these and leave every existing one alone.
The reason is the whole point of the corpus. These bytes exist to catch a format change
that quietly breaks a compatibility direction. A fixture regenerated by the build that
broke it agrees with that build by construction and catches nothing — which is exactly
the failure mode a version ladder exists to prevent. The same argument forbids a test
that builds its own fixture at run time. The repo-root `.gitattributes` (`*.rsbank
binary`) keeps this mechanical: without it, git's NUL-sniffing heuristic could
text-classify a future short/ASCII fixture and CRLF-mangle a line ending on a Windows
checkout, silently breaking the frozen-bytes premise.
A fixture's BYTES are frozen forever; a fixture's ASSERTION is not. `additive_forward.rsbank`
and `refuse_structural.rsbank` carry version pairs one step past THIS build's ladder (2/1
and 2/2). When a future build's own `kPackageFormatVersion` reaches 2, `refuse_structural.rsbank`
classifies `Readable` under the new ladder — its bytes never claimed to need more than
format 2 — so that build re-aims the assertion (and adds a new synthetic pair one step
past the NEW ladder); it never re-cuts the fixture. If a truncation or hostile-name
fixture ever changes classification, that is a regression, never a ladder consequence.
## Provenance
`v1_shipping.rsbank` was produced by running this repo's own export verb (`exportBank`)
at version **1.4.0** over a one-sample bank, and copying the emitted file here verbatim.
Every other fixture is derived from those bytes: eight of the nine truncations are
prefixes of `v1_shipping.rsbank` (the ninth, `trunc_additive_forward.rsbank`, is a prefix
of `additive_forward.rsbank` itself — a prefix of a prefix, still frozen bytes, never
regenerated), and the synthetic packages reuse their manifest region under different
version integers or a hand-written hostile manifest (the encoder refuses to write one,
which is why those could not come from the verb).
Payloads are one 300-byte 16-bit mono WAV. The properties under test are structural —
version integers, framing arithmetic, name validation — so a larger payload proves
nothing extra and costs the repo bytes forever. Whole corpus: ~14 KB.
Adding a fixture for a future version means writing it with **that** version's shipping
build, exactly as this one was, and recording the build's version here.
## What each fixture proves
### The three version fixtures
| File | `formatVersion` / `minReaderVersion` | Verdict | Proves |
|---|---|---|---|
| `v1_shipping.rsbank` | 1 / 1 | `Readable` | This build reads what it wrote: header, one manifest entry, the entry digest, and every `Sample` field with every optional present. Writer semver `1.4.0` is asserted **literally**, not against `stampVersion()` — comparing against the running build would let a version bump re-anchor the fixture silently. |
| `additive_forward.rsbank` | 2 / 1 | `Readable` | An additive newer writer still reads. Carries three keys this build has never heard of — `exportTool` at the manifest root, `futureEntryKey` on the entry, `futureSampleKey` inside the nested `Sample` blob — and decodes to *exactly* the manifest `v1_shipping.rsbank` decodes to. Writer semver `1.9.0`. |
| `refuse_structural.rsbank` | 2 / 2 | `TooNew` | A structural newer writer is refused whole. The header through the writer semver still reads, so the refusal can name all three facts (`1.9.0`, needs format 2, this build reads 1); no manifest, no layout, no partial success. Its body is `v1_shipping.rsbank`'s own manifest, which parses — so the refusal is a **decision**, not an inability. |
### Truncation — one file per distinct decode failure site
The first eight are prefixes of `v1_shipping.rsbank` (907-byte prefix + 300-byte payload
= 1207 bytes), so `formatVersion` never exceeds this build's on that path. All classify
`Malformed`; none may classify `TooNew`, since "install a newer build" does not fix a
partial download.
| File | Bytes | Site the cut lands in |
|---|---|---|
| `trunc_magic.rsbank` | 2 | Inside the 4-byte magic. |
| `trunc_version_pair.rsbank` | 10 | Inside the frozen header's `minReaderVersion` u32. |
| `trunc_writer_semver.rsbank` | 18 | Inside the frozen header's writer semver. |
| `trunc_manifest_length.rsbank` | 23 | Inside the manifest-length u32. |
| `trunc_manifest_body.rsbank` | 466 | Inside the manifest JSON. |
| `trunc_payload_start.rsbank` | 907 | At the payload boundary. RSBK stores no layout section — the layout is derived from the manifest's entries — so this is the cut that exercises "manifest parses, layout computes, exact-size proof fails". |
| `trunc_payload_middle.rsbank` | 1057 | Inside the first payload. |
| `trunc_one_short.rsbank` | 1206 | One byte short of the total. |
`trunc_additive_forward.rsbank` is the ninth: a 983-byte prefix of `additive_forward.rsbank`
(25-byte frozen header/manifest-length region + 958-byte manifest = 983), cut exactly at
ITS payload boundary. `formatVersion` here is 2 — one past this build's — so this is the
one truncation that proves the exact-size-proof failure stays `Malformed` even when
`formatVersion > kPackageFormatVersion`, rather than relabeling to `TooNew` (the parse
branch is the only one that relabels — see `src/core/package/CLAUDE.md`).
### Hostile names — refused at decode, before any planner
The two naming fields carry different rules (`src/core/package/CLAUDE.md`), so each
fixture keeps the other field spelled cleanly (`kick.wav`) and the refusal is
attributable to the field under test.
Entry name — a bare file name, no path expression possible (`isValidEntryName`):
| File | Entry name |
|---|---|
| `hostile_name_dotdot.rsbank` | `..` |
| `hostile_name_parent_slash.rsbank` | `../evil.wav` |
| `hostile_name_parent_backslash.rsbank` | `..\evil.wav` |
| `hostile_name_subdir_slash.rsbank` | `sub/evil.wav` |
| `hostile_name_drive_absolute.rsbank` | `C:\Windows\evil.wav` |
| `hostile_name_unc_absolute.rsbank` | `\\srv\share\evil.wav` |
Nested `Sample::relativePath` — a path by design, refused only for traversal and
absolute forms (`isValidNestedSamplePath`):
| File | `relativePath` | Guard that fires first inside the codec |
|---|---|---|
| `hostile_path_dotdot_slash.rsbank` | `bank/../../evil.wav` | `isValidNestedSamplePath` |
| `hostile_path_dotdot_backslash.rsbank` | `bank\..\evil.wav` | `isValidNestedSamplePath` |
| `hostile_path_rooted.rsbank` | `/etc/evil.wav` | `BankModel::add`'s absolute-path rejection, which drops the record and leaves the nested blob holding zero samples |
| `hostile_path_drive_absolute.rsbank` | `C:\Windows\evil.wav` | as above |
| `hostile_path_unc_absolute.rsbank` | `\\srv\share\evil.wav` | as above |
Both guards are inside the codec and both refuse the whole package, so the security
property is the same either way; the split is recorded because a change to either guard
alone would still leave these fixtures passing.
### The round-trip anchor
`v1_shipping.rsbank` doubles as it: the file **is** a real export, so importing it and
exporting the resulting bank closes export → import → export over frozen bytes. Entry
names may legally change across the trip (the importer re-spells a bank file, the
exporter mints its own transport name); the payload bytes may not.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More