diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6974000 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.rsbank binary diff --git a/CLAUDE.md b/CLAUDE.md index 46f3912..1491952 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `_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 `_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 diff --git a/CMakeLists.txt b/CMakeLists.txt index a9ab8c6..1fc67ae 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/README.md b/README.md index cdf1a9b..a32bc0a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/COMPLETED.md b/docs/COMPLETED.md index b57fbee..4456da6 100644 --- a/docs/COMPLETED.md +++ b/docs/COMPLETED.md @@ -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 `` 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). diff --git a/docs/PLAN.md b/docs/PLAN.md index dcd3219..096ab81 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -6,8 +6,9 @@ dispatched against directly — **plus Phase Γ**, which did not come from those (it came from a direct interview, 2026-08-01) and is scoped in `docs/product/instrument-control-surface.md`, **and Phase Ψ**, which likewise did not come from the seventeen: it came from a direct list of seven defects and refinements -(Daniel, 2026-08-01) and is specified inline in its own section below — there is no -backing product doc for it, **and Phase Ε**, which likewise did not come from the +(Daniel, 2026-08-01); it has landed, and now that its section here is retired, the list +is carried verbatim in `docs/COMPLETED.md` — there is no backing product doc for it, +**and Phase Ε**, which likewise did not come from the seventeen: it came from a direct request (Daniel, 2026-08-02) and is scoped in `docs/product/bank-package.md`, **and Phase Ρ**, likewise a direct request (Daniel, 2026-08-02), scoped in `docs/product/render-in-place.md`, **and Phase Λ** — the Linux port @@ -76,8 +77,9 @@ the stage lengths to 10s"* — the ceiling moves in Γ-W1-T1. **Γ-F7** (the VST RULED**, same day (Daniel, 2026-08-02): **Ε-F1** container format — *"proprietary container"*, the hand-rolled `RSBK`; **Ε-F2** import target — *"always lands as a new bank, with an auto suffix if name collision"*; **Ε-F3** import under a degraded tracking ledger — -*"refuse mismatched import."* The rulings are folded into the tracks below and indexed at -`docs/product/bank-package.md` §"Rulings". **Two of the three landed somewhere other than +*"refuse mismatched import."* Ε-W1 through Ε-W3 have landed; see `docs/COMPLETED.md` for +the full narrative, and the rulings remain indexed at `docs/product/bank-package.md` +§"Rulings". **Two of the three landed somewhere other than the framing recommendation:** Ε-F2 dropped the proposed rename prompt in favour of a deterministic suffix, and Ε-F3 reversed allow-with-confirm to refuse — carried by the framing's own counter-argument, that the accepted tracking residual contemplates *one* @@ -90,8 +92,9 @@ decision.** RULED**, same day (Daniel, 2026-08-02): **Ρ-F1** multi-track — *"refuse"*, one track per fire, a settled non-goal rather than a deferral; **Ρ-F2** the result track's mode — *"for this action which is not a capture, the result track should always go to -arrange"*; **Ρ-F3** tail — *"follow panel tail settings."* The rulings are folded into -the track below and indexed at `docs/product/render-in-place.md` §"Rulings". **Ρ-F2 +arrange"*; **Ρ-F3** tail — *"follow panel tail settings."* Ρ-W1-T1 has landed; see +`docs/COMPLETED.md` for the full narrative, and the rulings remain indexed at +`docs/product/render-in-place.md` §"Rulings". **Ρ-F2 overrode the request's own original wording** ("stays in whatever mode was active") and is the only one of the three that changed the spec: the result track is now an Arrange member unconditionally, the A/B-on-the-bench behaviour mode-following would have enabled @@ -124,7 +127,7 @@ carries them as a table. What they gate is narrow and is not a matter of judgeme different kind of unknown, and the reason Λ-W3 exists. **Λ's six [Daniel] rulings of 2026-08-02 are settled** and are recorded in the phase section -below in the same shape as Γ's and Ε's: the instrument is in scope; SWELL is reached by +below in the same shape as Γ's: the instrument is in scope; SWELL is reached by `dlopen`ing REAPER's own `libSwell.so` rather than vendoring it; the editor is REAPER-only but every other host must degrade safely; macOS is out; the Linux artifact is shipped rather than developer-only; and a hard `unlink` prune is acceptable with a platform-aware @@ -187,11 +190,9 @@ in Γ-W4-T1 and changes no wave boundary. ## Phase-wide acceptance criteria -These bind every track in all four phases and are stated once here rather than repeated -per track. **Phase Γ adds a set of its own**, stated in its phase header. **Phase Ψ adds -none** — the structural heuristics, performance guardrails, and product invariants below -bind it exactly as written (its phase header states its performance posture against the -named hot paths). +These bind every track in both phases and are stated once here rather than repeated +per track. **Phase Γ adds a set of its own**, stated in its phase header. **Phase Λ adds +a set of its own**, stated in its phase header. ### Structural (root `CLAUDE.md`, Daniel 2026-07-28) @@ -240,345 +241,6 @@ touches them. The instrument adds a sixth surface that binds every Θ track: --- -## Phase Θ — ReaSampler 9000: one parameter set, a filter, shapeable envelopes, a legible editor - -**Ships:** the instrument with the zone system retired, a resonant HP/LP filter stage in -the voice path, curve-shapeable and spline-drawable envelopes on all three EGs, -Gate-mode loop sustain, and a re-laid, high-DPI-clean editor — plus the two extension -drag/drop defects that block getting captures into it. - -**Consolidates items** 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16. - -**All seven waves have landed — Phase Θ is complete.** W1 through W7 each carry their own -landed note above; see `docs/COMPLETED.md` for every track's full narrative. Θ-W7 was -opened after Θ-W6-T1 shipped, to fix two rendering defects Daniel found by eye; it did not -exist in the plan when this phase was originally scoped. - -**The organizing constraint.** Three surfaces in the instrument are single-writer by -nature and dictate the wave shape: - -1. **The parameter model** (`zone_params.h` + `component_state_io`) — every parameter - addition touches both. Two tracks adding parameters concurrently is a merge fight - and two competing `ComponentState` version bumps. -2. **The voice render path** (`sampler_core.cpp`) — filter insertion, envelope - evaluation, loop read, and the Trigger tail all live there. -3. **The Sample face** (`editor_paint_sample.cpp` 516 lines / `editor_input_sample.cpp` - 583 lines) — every UI item repaints it, and both are already at the ceiling. - -Parallelism in this phase therefore comes from **splitting the Sample face into bands** -(done once, in Θ-W1-T1) and from **extension-side work being genuinely disjoint** — not -from running two parameter-model tracks at once. Where a wave has one track, the -collision is real and the serialization is the correct answer. - ---- - -### Θ-W1 — Collapse and re-seam - -**All three tracks have landed** — Θ-W1-T1 (`zone-retirement`), Θ-W1-T2 -(`capture-handoff-bugs`), and Θ-W1-T3 (`filter-dsp-port`) — see `docs/COMPLETED.md` for -the full narrative of each. Between them: the zone subsystem is retired, the wave's two -responsibility seams (the `sampler_core` split, the Sample-face band split) are in -place, the two extension-side capture-handoff defects are fixed, and the filter DSP has -landed as a standalone pure module — Θ-W2-T1 has since wired it into the voice path (see -below). - ---- - -### Θ-W2 — Filter in the voice path; waveform and chrome bands - -**Depends on W1 for:** the one-parameter-set model and the `sampler_core` seam that T1 -writes the filter into; the `filter` module T1 wires up; the Sample-face band split and -band-stack allocator that T2 and T3 fill; and W1-T1's key-range answer, which decides -what T3's piano strip displays. Authoring any of this against the per-zone model means -writing storage plumbing W1 deletes. - -**All three tracks have landed** — Θ-W2-T1 (`filter-voice-path`), Θ-W2-T2 -(`stereo-waveform-lanes`), and Θ-W2-T3 (`toolbar-and-piano-strip`) — see -`docs/COMPLETED.md` for the full narrative of each. Between them: the filter sits in the -per-voice signal path with its own deck, the waveform band shows both channels in -stereo mode behind a type-enforced full-height overlay contract, and the chrome band's -toolbar and piano strip are cleaned up per spec. The three tracks were disjoint by -band — T1 owned the parameter model and the deck band, T2 owned the waveform band, T3 -the chrome band — and none re-allocated the band stack. - ---- - -### Θ-W3 — Live parameters, then the staged envelope system - -**Depends on W2 for:** the filter envelope's existence — items 1, 8, and 14 govern -*three* envelopes, and the filter is the third; and for the Filter deck, which must -exist before it can receive a corner radio switch and inner curve dials. T1 additionally -depends on W2-T1 for the filter itself: the filter is the first control set where the -latched-at-note-on delivery model fails audibly, and it is what made the defect visible. - -**Both tracks have landed** — Θ-W3-T1 (`live-parameter-delivery`) and Θ-W3-T2 -(`staged-envelope-curves`) — see `docs/COMPLETED.md` for the full narrative of each. -Between them: every continuous playback control (filter cutoff/Q/morph/drive/mod -amount/key-track, every stage time and level on all three envelopes) now reaches a -sounding voice live instead of latching at note-on, and the envelope-overlay editor -grew from an amp-only fixture into the shared graphical surface for all three -envelopes — a corner radio switch per deck (none active by default), curve-shapeable -segments on every sloped stage (0.1–10 exponent, an inner dial paired with an overlay -knot), the release-right-anchored AHDSR layout against the pitch envelope's 1:1 AHD, -and the Trigger amp/filter fade pair folded into a Trigger AHD, consolidating what -were two staged-shape mechanisms into one. The Trigger × Preserve end-of-sample click -is fixed; the landed fix is wider than scoped, also ringing out Gate × Preserve × -source-exhaustion, previously a hard cut. The ordering was deliberately serial — T1's -live-delivery mechanism landed first so T2's new curve exponents and Trigger AHD -fields were authored directly into it rather than backfilled afterward. - ---- - -### Θ-W4 — Loop sustain and the velocity deck - -**Depends on W3 for:** the envelope system both tracks compose with — T1's loop-sustain -is the Gate-mode sustain the AHDSR releases out of, and T2's bipolar pitch/filter curves -modulate targets whose envelopes W3 just reshaped. T2 additionally depends on W2-T1 for -the filter's existence and on W2-T3 for the preview button's toolbar position. - -**Both tracks have landed** — Θ-W4-T1 (`gate-loop-sustain`) and Θ-W4-T2 -(`velocity-deck-and-bipolar-curves`) — see `docs/COMPLETED.md` for the full narrative of -each. Between them: loop points are now a usable feature, with a Gate-mode loop acting -as the sustain and a parameterized crossfade at the seam; and the three velocity-curve -popups (amp, pitch, filter) now live together in a new VELOCITY deck group, with the -pitch and filter curves bipolar and flat-by-default so their modulation is off until -drawn, while the amp curve stays unipolar and unchanged, and the preview button's text -is replaced by a drawn play-triangle glyph. Params payload reached v11 with T1's loop -block and v12 with T2's velocity→pitch curve appended after it. - ---- - -### Θ-W5 — Spline EGs - -**Depends on W4 for:** the bipolar velocity-curve domain (W4-T2) — the spline algorithm -is **singly implemented and multi-referenced**, so its enhancement must land against the -final consumer set, and the last consumer to change domain is the pitch/filter velocity -curve; and for the Gate-mode loop (W4-T1), since "Gate is unavailable in Spline mode" is -only a real, testable rule once Gate has something to be unavailable *for*. It also -depends on W3's radio switch, which is how a spline contour reaches the overlay at all. - -**Θ-W5-T1 has landed** — `spline-egs` — see `docs/COMPLETED.md` for the full narrative. -It shipped a free-drawn alternative to every staged envelope: pitch, filter, and amp -EGs can switch Staged → Spline and have their contour drawn directly on the waveform -overlay. The one shared monotone-spline implementation gained hard points — sharp -corners, no smoothing on either adjacent segment — flowing to every consumer including -the existing velocity→amp transfer curve, no fork. Both Staged and Spline state persist -simultaneously (saved-but-inactive, lossless round-trip); params payload reached v13, -and v12 projects still load. Gate is unavailable while a Spline EG is active; the -contour is a pure time function over the full sample length, normalized and drawn 1:1 -with the sample's time axis. Point grammar converged on left-click add / right-click -delete / control-click hard-smooth toggle, one grammar across both spline consumers. -Staged segment knobs and their inner curve dials render disabled and reject edits while -Spline is active. Point-count ceiling: 128, a musical bound rather than a performance -one. A follow-on change in the same track reworked deck cell width: `-1` in `cellIds` -now means one cell's width, reserved and redistributed, rather than a blank cell holding -geometry — a Trigger face that drops Sustain and Release gets wider cells instead of -dead slots; group widths, row packing, deck height, and Gate-mode cell widths are -unchanged. - ---- - -### Θ-W6 — Editor legibility pass - -**Depends on W5 for:** the last change to a drawn surface. Item 13 is an audit whose -output is a disposition list over "every class of drawn surface," and item 10's sizing -pass is judged by eye over the finished layout — running either while the spline contour, -the disabled-knob state, or the deck inventory was still moving would have meant -auditing and then re-auditing. Θ-W5-T1 has landed (see `docs/COMPLETED.md`), so that -surface has stopped moving and this dependency is satisfied. The source doc names this -sequencing as an observation (item 13 after the layout/knob work); this plan adopts it -as the boundary. - -**One track.** Both items repaint essentially every surface in the editor; concurrent -tracks would collide everywhere. - -**Θ-W6-T1 has landed** — `legibility-and-antialiasing` — see `docs/COMPLETED.md` for the -full narrative. It shipped both halves together: knobs grew 28→40 px (inner curve dial -14→20), the deck cell 48×58→60×74, and the label band 12→16 px, now drawn in -`Font::Label` rather than `Font::Micro` — group captions and toggle segments deliberately -stayed `Font::Micro`, since bumping them would outgrow row 1's headroom at the floor -width. The editor's default/minimum size grew 840×620→980×680 to fit the wider deck at -floor width; an existing saved instance's window grows on open, and the floor is -validated by a derived test rather than literals. All fourteen time-constant labels now -read in ms (display-only; internal representation untouched) — `holdFraction` knobs, -`Len %`, and the bank panel's duration readout stayed out of scope. Double-click resets -each ring independently — outer ring resets the value, inner dial resets the exponent to -1.0 — reaching the chrome's preview-velocity knob too via a shared `inKnobFace` rule. - -The antialiasing audit fixed knob arcs and needle, the inner dial arc and needle, staged -and spline envelope slopes, the spline contour, the velocity-popup trace, the waveform -outline, and the preview triangle; node handles, curve knots, knob discs, buttons, piano -keys, loop markers, borders, gradients, and text were already clean. The disposition -table is a standing artifact in `docs/product/visual-design-language.md` §8. The -piano-key open question is answered: not aliasing — every key is an axis-aligned -`LICE_FillRect`, so the earlier width defect was integer-division residue in the tiling, -not a sloped edge. High-DPI host scaling itself is unverified (deferred, see -`docs/TODO.md`). - -All visual outcomes remain pending Daniel's by-eye sign-off on `dev`. - ---- - -### Θ-W7 — Arc-and-spline antialiasing fix - -**Depends on W6 for:** a drawn surface to find a defect on — this wave did not exist in -the plan; it was opened after Daniel found two rendering defects by eye once Θ-W6-T1 -shipped, so the audit's own output is what surfaced them. - -**One track.** - -**Θ-W7-T1 has landed** — `arc-and-spline-aa` — see `docs/COMPLETED.md` for the full -narrative. The stacked-`LICE_Arc` knob and dial rings never reached an opaque core (peak -alpha measured 138/255), and the staged/spline envelope and velocity-curve traces were -fully aliased rather than gapped, from integer `cy` quantizing the slope. Both defects, -plus the two needles, now route through one pure analytic thick-stroke rasterizer — -coverage in a new `core/ui/stroke_aa`, the single LICE blend in a new -`shell/instrument/editor_stroke` — replacing `LICE_Arc` and `LICE_ThickFLine` outright. -Measured: arc peak alpha 138/255 → 255/255, arc perpendicular-weight ripple 67% → 5%, -spline weight ripple 29% → 3%, at a cost of +0.09 ms per full editor repaint. Daniel then -ruled every sub-2 px stroker width up to 2 px, since the stroker only guarantees an -opaque core at width ≥ 2 px; the knob track arc, the inner-dial needle, and the deck's -mini velocity trace each moved 1.0 → 2.0 px. `docs/product/visual-design-language.md` §8 -is corrected — a false "keeps every ring antialiased" claim is deleted, the rows are -re-dispositioned with measurements, and the audit's methodological lesson (verifying -which primitive was called is not verifying what it rasterized) is recorded as a standing -blockquote. All visual outcomes remain pending Daniel's by-eye sign-off on `dev`. - ---- - -## Phase Ξ — The resample loop - -**Ships:** one consolidated, fully robust provenance/usage tracking system, and on top of -it the one-click in-sampler resample — dial → bake → dial again, with the bank as the -medium each iteration passes through. - -**Consolidates items 15, 17.** - -**Why these two and not more.** Item 17 is a **prerequisite of meaning** for item 15's -bank-side half: the replace-vs-add rule is "does provenance-tied usage exist," which -denotes nothing until the consolidated lineage records exist. Item 17 is otherwise -independent of the editor chain — which is what makes Ξ-W1 concurrency-safe with Θ. - -**Ξ-W2 onward requires Phase Θ complete.** Item 15 presupposes the processing surface it -bakes — "filtering, pitching, amp all set up nice" is the instrument items 1, 2, 3, and 14 -build — and its settled reset scope enumerates the filter parameters, the spline contours, -and the loop points by name. Baking a processing chain that does not exist yet is not a -schedule preference; the feature is not expressible. Phase Θ landed as of Θ-W7-T1 (see -`docs/COMPLETED.md`); this gate is satisfied. - -**All three waves have landed — Phase Ξ is complete.** W1 through W3 each carry their own -landed note below; see `docs/COMPLETED.md` for every track's full narrative. - ---- - -### Ξ-W1 — Consolidated tracking, and the programmed-note model - -#### Ξ-W1-T1 — `tracking-consolidation` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. The provenance/usage -territory is now one system: a new `src/core/tracking/` directory holds `origin_ledger` -(the record family — `OriginRecord`/`OriginKind`, the insertion-ordered `OriginLedger`, -its JSON codec, and the `Fresh`/`Loaded`/`Unreadable`/`FutureVersion` load -classification) and `tracking_authority` (the one decision surface: `pruneProtection` -and `tiedUsageExists`), retiring `core/model/owned_manifest`. Both prune's protected set -and the resample's replace-vs-add decision are computed from one borrowed -`TrackingState`, so the two safety-critical consumers cannot drift apart. -`sample_usage` deliberately **stays in `core/wire`** — the consolidation is of the -decisions, not the codecs. The deferred persisted-instance-identity fix was **not** -folded in — the deferral is restated in `docs/TODO.md`, its one home. This track -landed `OriginRecord`'s birth-time `parentSampleId` chain as the lineage mechanism, but -**Ξ-W2-T1's "naming and lineage" open question (jointly held with this track) is -unaffected and still theirs to close** — display naming and user-readable iteration -lineage were not decided here. - -#### Ξ-W1-T2 — `note-program-model` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. The programmed-capture- -signal model is a new pure module directory, `src/core/instrument/note/` — a fourth -peer of `engine/`/`map/`/`ui/` under `core/instrument/` — holding `musical_division`, -`tempo`, and `note_program` (`Velocity`, the denominated `OffsetAmount`, the anchored -`StartOffset`/`EndOffset`, `NoteProgram`, `resolveNote`). **Both open questions below -are answered, for Ξ-W3-T1:** negative offsets are legal in both directions (sign -uniform, positive is later in time; only an inverted window is refused, reported via -`ResolvedNote::windowCollapsed`), and the denomination seam is confirmed as of this -landing — note length stays musical-division-only, and an offset stores the denomination -it was entered in. **Superseded by Ξ-W3-T1's landed work** (see `docs/COMPLETED.md`): -`note/CLAUDE.md`'s musical-division-only rule is amended — a note length now carries -EITHER an exact derived duration or a musical division, not division-only. The offset -denomination rule is unaffected. - ---- - -### Ξ-W2 — The bake chain - -**Depends on Ξ-W1 for:** the consolidated lineage records that make replace-vs-add -computable (T1) and the programmed-note record the offline pass renders (T2). **Also -depends on all of Phase Θ** — see the phase note above. - -**This wave ran AHEAD of Phase Γ, and that was not a choice.** The plan asserted Γ must land -first so the bake's reset list would be complete on the day it shipped; Ξ-W2-T1 was already -live. The consequence is owned, not absorbed: **Γ-W3-T2 `bake-reset-amendment`** completes -the list afterwards, and **Γ-W4-T1** adds the host-notification obligation once parameters -exist. Neither is this wave's work, and neither is a defect report against it. - -**One track.** The bake is one gesture and one chain; the architecture decision at its -head governs every step after it. - -#### Ξ-W2-T1 — `resample-bake-chain` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. The architecture decision -(this track's first deliverable) is ratified: **Decision 1 = (1b)**, the editor invokes -the extension's bake action directly over the VST-host bridge -(`NamedCommandLookup`/`Main_OnCommandEx`), no poller/nonce, dissolving the S13 DEGRADED -verdict; **Decision 2 = (2c)**, the instrument renders in-process and the extension -banks the file, taken over the plan's leaning toward (2a) on an engine-version-skew -argument. **Extension presence** resolved to cleanly-unavailable, not silently lossy — -the affordance refuses up front when the extension is not loaded. **Reset-scope edge -cases** classified against the ratified rule: play mode → RESET to Trigger, start point -→ RESET, channel mode and preview velocity → SURVIVE. **Phase Γ's additions remain -NOT this track's** — Γ-W3-T2 `bake-reset-amendment` still amends the reset list for -Γ's own new values, unaffected by this landing. **Naming and lineage** is proposed -(`Kick` → `Kick r2` → `Kick r3`) but not itself ratified — still open jointly with -Ξ-W1-T1's lineage-record question, which Ξ-W1-T1 answered on its own side -(`OriginRecord::parentSampleId`). Nothing was verified in a live REAPER session; -Daniel's manual verification is still owed. - ---- - -### Ξ-W3 — The capture-signal popup - -**Depends on Ξ-W2 for:** the bake chain this track was originally scoped to build a -preview against, under the acceptance criterion "preview and bake cannot diverge." **That -motivation is retired, not satisfied** — see below. - -**One track.** - -**Ξ-W3-T1 has landed** — `capture-signal-popup` — see `docs/COMPLETED.md` for the full -narrative, and it diverges substantially and deliberately from this section's spec. The -popup this track was scoped to build (a musical-division note-length picker, ms/beat- -editable offsets, velocity, and a preview trigger) was built (~1500 lines) and then -**abandoned unmerged** on Daniel's ruling: *"I didn't realize you had already derived a -usable window. The manual stuff for baking a specific midi length was just an idea, if we -have a smarter, fewer-clicks way of doing it, that is ideal. I just don't want to lose -anything when we bake. We can abandon the whole parameterized bake window if we can -safely derive the window in gate and trigger modes."* An audit, backed by executable -tests, established the window derives losslessly everywhere except Gate over an active -sustain loop, which has no intrinsic duration to derive. What shipped instead: the bake -window derives itself in Trigger and loop-less Gate; one control, a musical-division -**Hold** picker, covers the one irreducible case and is shown only for Gate-with-active- -loop (`bakeWindowNeedsHold`, reading the engine's own loop fold); bake velocity now reads -the instance's persisted preview velocity rather than a hard-coded value; and the -chrome-row play button stays a pure MIDI trigger — Daniel's ruling: *"play button is pure -MIDI trigger, Bake parameters are their own thing."* **This retires this section's -acceptance criterion 2 ("preview and bake cannot diverge") and its preview-trigger -behavior bullet by ruling, not by shortfall** — there is no popup and no preview trigger. -Three pre-existing truncation bugs were found and fixed along the way, and -`note/CLAUDE.md`'s musical-division-only invariant is amended (see Ξ-W1-T2 above): a note -length now carries either an exact derived duration or a musical division, not -division-only. - ---- - ## Phase Γ — The instrument's control surface **Ships:** the deck reflowed into two categorical rows with a double-height MASTER bus deck, @@ -1076,6 +738,10 @@ path entirely, so a capture baked with the limiter engaged came back unlimited. (below) has since closed that gap** — the limiter is printed too now, and `bake/CLAUDE.md`'s invariant text is corrected again to match. +**Spec:** `docs/product/instrument-control-surface.md` §3.4, and Ξ-W2-T1's own "Reset scope" +block, now in `docs/COMPLETED.md` (Phase Ξ has landed and its `PLAN.md` section is retired) — +**which is the ratified rule this track applies, not a rule it may reinterpret.** + **Neither track has been verified in a running DAW; both are asserted in CTest only.** #### Γ-W3-T3 — `bake-prints-limiter` @@ -1139,1176 +805,6 @@ residual DAW-verification items. --- -## Phase Ψ — The extension trust pass: exact bounds, disjoint solo surfaces, reachable actions, honest drops, real names, true mono - -**Ships:** capture ranges that mean what was asked — a time selection or razor area over -a longer item captures the selection, both scopes; per-mode solo surfaces that cache, -clear, and restore across the Design/Arrange switch, with the switch itself refused while -the transport runs; the Media-Explorer import action registered into the Media Explorer -action section so it can live on that toolbar; a drag-out gesture law that resolves its -target from what is actually under the cursor, continuously and reversibly, with no -silent no-op release anywhere; captures labeled after their source track instead of the -literal `"item"`/`"track"`, surfaced on the panel card; and lossless mono collapse for -new captures whose channels are bit-identical. - -**Consolidates: none of the seventeen.** Phase Ψ came from a direct list of seven defects -and refinements (Daniel, 2026-08-01). There is no backing product doc — the design -content lives inline in the tracks below, and the seven are recorded here as the phase's -provenance, cited throughout as Ψ.1–Ψ.7: - -> **Ψ.1** — item and track captures should be named (labeled) after their source track -> name, plus a discriminator (date, etc.); currently they aren't named anything useful. -> **Ψ.2** — Design vs. Arrange modes: any SOLO state in one mode is cached and removed -> when switching to another mode, disjoining the solo surfaces. -> **Ψ.3** — the Design/Arrange active-mode toggle is gated if playback is running; only -> allow the switch when the project is not playing. -> **Ψ.4** — the action that moves a Media Explorer item to a new ReaSampler on the -> selected track is not in the Media Explorer action category, so it cannot be added to -> the Media Explorer toolbar; fix this. -> **Ψ.5** — drag-and-drop targets are inexact: sometimes dropping into the arrange -> doesn't work, sometimes dropping into the FX area doesn't work; dragging between banks -> is fine. -> **Ψ.6** — capture into mono: if the left and right channels of a new capture are -> bit-identical, collapse to mono — one channel of data, mono arrange items, ReaSamplers -> load in mono mode. -> **Ψ.7** — capture item / capture track with a small time selection on a large item -> captures the entire item, not the selection/razor; make capture regions consistent and -> correct. - -**Where the seven land:** - -| Ψ-item | Track | Worktree slug | -|---|---|---| -| Ψ.7 | Ψ-W1-T1 | `ppsi-w1-t1-capture-range-exactness` | -| Ψ.2 + Ψ.3 | Ψ-W1-T2 | `ppsi-w1-t2-mode-switch-discipline` | -| Ψ.4 | Ψ-W1-T3 | `ppsi-w1-t3-media-explorer-section` | -| Ψ.5 | Ψ-W1-T4 | `ppsi-w1-t4-drop-target-resolution` | -| Ψ.1 | Ψ-W2-T1 | `ppsi-w2-t1-capture-naming` | -| Ψ.6 | Ψ-W2-T2 | `ppsi-w2-t2-mono-collapse` | - -**Ψ-W3 is not one of the seven** — opened mid-phase, after Ψ-W2's review surfaced that -the track scope carried the same multi-track stem-collapse hole Ψ-W1-T1 had just closed -for item scope. It consolidates none of the original seven and carries no `Ψ-item` row -above; see Ψ-W3 below. - -**All three waves have landed — Phase Ψ is complete.** W1 and W2 each carry their own -landed notes below; W3 carries its own too. See `docs/COMPLETED.md` for every track's -full narrative. **None of the seven tracks is DAW-verified** — all are code-complete and -unit-tested, several resting on a shared unverified inference about how REAPER's -selected-tracks render source interacts with custom time bounds, which Ψ-W3-T1's -refusal now also rests on; each track's DAW-verification obligation is restated inline -below. - -Ψ.2 and Ψ.3 share one track deliberately: they share one chokepoint — `applyMode`, the -sole mode mutator (`shell/view/view.cpp:380-469`) — and one discriminator -(`targetModeId != model.activeModeId()`, the test that distinguishes a real switch from a -reapply). Splitting them is two tracks fighting over the same function. - -**Concurrency with Γ and Ξ.** Phase Ψ is extension-side. Γ and Ξ-W3 live in -`core/instrument/` + `shell/instrument/`; the surfaces are disjoint with ONE named -exception — Ψ-W2-T2 touches `shell/instrument/processor_reload.cpp` for a stale comment -and an index/file consistency check, and that touch is bounded to the minimum in its -surface boundary precisely because Γ is live in that directory. - -**Three invariant amendments were DELIVERABLES of this phase, not asides.** Each landed -in its owning track, as an acceptance criterion of that track: - -1. **The never-touch-solo rule** (Ψ-W1-T2): `src/shell/view/CLAUDE.md:14-17`, - `src/core/view/CLAUDE.md:10`, and `docs/product/design-view.md:160-165` + `:588-592`. - Ψ.2 requires writing `I_SOLO`; the amended form is stated in the track. A track that - lands solo writes without the amendment reads as an invariant breach in review. -2. **The action-registration contract** (Ψ-W1-T3): root `CLAUDE.md` - §"REAPER extension contract" documents only the main-section 4-step pattern; the - second, non-main mechanism (`custom_action` + `hookcommand2` + `-custom_action` - mirror) must be added beside it. -3. **The channel-count-preserved rule** (Ψ-W2-T2): root `CLAUDE.md:208` — "channel count - preserved (no silent stereo fold)" — is contradicted in text (not in spirit) by a - lossless bit-identical collapse, and was already untrue in the other direction (a mono - source renders at `RENDER_CHANNELS=2` today). The amended form is stated in the track. - -**Performance posture.** Every Ψ surface is cold — per-capture, per-click, -per-mode-switch, per-mouse-move. None of the named hot paths (peaks envelope compute, -audition, realtime-capture tick, instrument `process()`) is touched. Two disciplines -carry anyway: the realtime tick's single-pointer-test idle fast path is untouched by -Ψ-W1-T1's render work, and Ψ-W1-T4 keeps the inside-client drag path free of SDK -hit-tests, exactly as today (`panel_drag.cpp:273-274` — "costs nothing on the common -internal-drag path"). - ---- - -### Ψ-W1 — Exact bounds, disciplined switches, reachable actions, resolved drops - -**Depends on:** nothing in this phase. - -#### Ψ-W1-T1 — `capture-range-exactness` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. A ranged item capture now -renders exactly the requested window instead of the whole item, by re-sourcing through -the selected-tracks render when the item extent does not already print the window. -**Deviation:** the spec named two candidate architectures (re-source vs. render-then- -trim); the engineer shipped a conditional form of the re-source candidate — the -full-extent case runs literally unchanged code, keeping the byte-identity regression -floor structural and the fix cheap to revert if the override inference proves wrong. -Also landed: a transient isolation guard (cutting `B_MAINSEND` on direct folder -children, muting receives) so an item capture stays true to item scope, and a -post-render frame-count gate (±1 tolerance, tail-None only) that refuses a widened -render and retains it outside the bank for diagnosis rather than deleting it. New modules `core/capture/render_window`, `core/capture/track_topology`, -`shell/capture/render_selection`, `shell/capture/render_isolation`. The whole fix rests -on the unverified inference that REAPER's selected-tracks render source overrides -custom time bounds — Ψ-W3-T1 (below) now also depends on it. **DAW-verification -obligation, unmet:** the four-cell scope × selection-type matrix over a source item -substantially longer than the selection (tail None), plus one razor-union case — none of -it run in a live REAPER session yet. - -#### Ψ-W1-T2 — `mode-switch-discipline` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. Per-mode SOLO surfaces now -cache, clear, and restore across a Design/Arrange switch, with the switch itself -refused, visibly, while the transport is playing or recording. The never-touch-solo -invariant — stated three times (`src/shell/view/CLAUDE.md`, `src/core/view/CLAUDE.md`, -`docs/product/design-view.md`) — is amended, in this track, to the snapshot sense of -non-destructive: solo is cached per mode on a real switch and restored verbatim, not -left untouched absolutely the way `B_MUTE` and the master track are. Also closed: a -pre-existing bug where a footer mode-segment click never persisted view state. New -`core/view/solo_cache`, `shell/view/view_solo`. **DAW-verification obligation, unmet:** -the disjoint-surface solo matrix, the playback-gated refusal (playing and recording), -and the panel-persist case — none run in a live REAPER session yet. - -#### Ψ-W1-T3 — `media-explorer-section` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. The Media Explorer import -action now registers into REAPER's Media Explorer action section (32063) via -`custom_action` + `hookcommand2`, alongside its existing Main-section entry so existing -keybindings survive — a second FOREVER-STABLE id, `INGEST_IMPORT_MEDIA_EXPLORER_MX`, -minted per channel. Root `CLAUDE.md`'s "REAPER extension contract" is amended, in this -track, with the second, non-main registration mechanism beside the original four-step -main-section pattern. **DAW-verification obligation, unmet:** adding the action to the -Media Explorer toolbar and firing it from there; confirming the Main-section binding -still fires; and confirming unload/reload does not leak a duplicate Media Explorer entry -(the `-custom_action` unload mirror is unconfirmed against the SDK header) — none run in -a live REAPER session yet. - -#### Ψ-W1-T4 — `drop-target-resolution` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. The drag-out gesture is now -a per-move, stateless law: target class resolves from what is under the cursor on every -move, every class transition is reversible until release or until the pointer leaves -REAPER, and the OS hand-off is reserved for leaving REAPER entirely — the whole TCP/MCP -is now an instrument-drop hotspot, and a single-card arrange drop lands a timeline item -at the pointer's track and time. New `shell/actions/arrange_drop_win`. -**DAW-verification obligation, unmet:** the full target-class matrix (single- and -multi-card), reversibility across a drag that crosses the arrange en route to an FX -window, drag-speed independence, and the narrow-TCP case — none run in a live REAPER -session yet. - ---- - -### Ψ-W2 — Names and channels, over the settled render block - -**Depends on Ψ-W1 — specifically Ψ-W1-T1**, which rewrites the capture -render-configuration block in `shell/capture/capture.cpp` and -`core/capture/render_settings.cpp`; both W2 tracks edit adjacent regions of those same -TUs, so W2 dispatches only after W1-T1 is on `dev`. (T2–T4 of W1 gate nothing here; the -wave boundary is the file collision, not a semantic dependency.) - -#### Ψ-W2-T1 — `capture-naming` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. Captures are now named -after their source track's name plus a discriminator -(` [+N] [#ordinal] MM-DD HHMM`) at every interactive mint site, with the name -shown on the panel card over a scrim clearing the 4.5:1 contrast floor. New -`core/capture/capture_name`. Recapture, ingest, and the bake deliberately keep their own -naming — the bake's naming-and-lineage open question stays Ξ-W1-T1's/Ξ-W2-T1's to close, -untouched here. **DAW-verification obligation, unmet:** capturing from a named track, an -unnamed track, and a multi-item selection, and confirming the labels show on the card -and in the instrument — none run in a live REAPER session yet. - -#### Ψ-W2-T2 — `mono-collapse` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. A capture whose channels -are bit-identical now collapses losslessly to one mono channel, written via temp file -plus atomic rename, with `Sample::channelCount` measured off the landed file rather than -echoed from the request on every capture path — including realtime, which previously -parsed the layout and echoed the request anyway. Root `CLAUDE.md:208`'s -channel-count-preserved invariant is amended, in this track, to the landed wording: -channel count preserved, except that bit-identical channels may collapse losslessly to -mono; a lossy fold remains forbidden. The `shell/instrument/processor_reload.cpp` touch -stayed to the minimum named in its surface boundary (Phase Γ was live in that -directory). **Open, not closed here:** whether bake landings collapse too — `[propose]`, -leaning yes, still deferred to be confirmed against what Ξ-W2-T1 actually shipped. -**DAW-verification obligation, unmet:** capturing a dead-center mono source and a -true-stereo source, inserting both, loading both into the instrument, and running the -null test on the collapsed one (REAPER's mono-item-on-stereo-track summing at unity is -the specific thing to confirm) — none run in a live REAPER session yet. - ---- - -### Ψ-W3 — Closing the track-scope stem-collapse hole - -**Depends on Ψ-W2 for:** existing at all — this wave did not exist when the phase was -scoped. Daniel opened it after Ψ-W2's review surfaced that the track scope carried the -same multi-track stem-collapse hole Ψ-W1-T1 had just closed for item scope. - -**One track. Consolidates none of the seven** — it came from a review finding, not from -Ψ.1–Ψ.7. - -#### Ψ-W3-T1 — `track-scope-range` - -**Landed** — see `docs/COMPLETED.md` for the full narrative. Any multi-track -selected-tracks render now refuses, in both scopes, keyed on the render *source* rather -than the capture scope — closing the hole Ψ-W1-T1 left open for track scope. Realtime -deliberately diverges and was left untouched, because it sums correctly. The refusal -rests on the same unverified inference Ψ-W1-T1 rests on — that REAPER's selected-tracks -render source overrides custom time bounds — so if that inference is wrong, this refusal -costs a working capture. `docs/verify-track-scope-multitrack.md` is a new standalone -verification script on this branch, for this track's multi-track refusal specifically. -**DAW-verification obligation, unmet:** confirmed nowhere in a live REAPER session yet. - ---- - -## Phase Ε — The bank package: one file that carries a bank between projects - -**Ships:** a bank exported to a single version-tagged `.rsbank` file — audio bytes -byte-exact, index metadata intact — and imported into another project's bank folder and -index, with a compatibility policy that names both directions concretely: an older package -in a newer build always imports, a newer package in an older build refuses whole with an -actionable message, and neither direction is ever a partial landing. - -**Consolidates: none of the seventeen.** Phase Ε came from a direct request (Daniel, -2026-08-02) and is scoped in `docs/product/bank-package.md`. Nothing in `docs/TODO.md` or -`docs/TODO-1.0.md` records export, import, or a package format — a sweep of `docs/` for -`export|package|portable` returns only unrelated matches (prune's portable move-to-trash, -MIDI-playback prose) — so this phase **supersedes nothing and absorbs nothing.** - -**Three forks were opened at framing and all three are RULED** (Daniel, 2026-08-02). The -rationale for each — including why two went against the framing recommendation — is -recorded at `docs/product/bank-package.md` §"Rulings"; the binding specification for each -is in the section of that doc named below. - -| Fork | Ruling | Specified in | Bound into | -|---|---|---|---| -| **Ε-F1** | **Proprietary container** — the hand-rolled `RSBK`. ZIP via the vendored MiniZip64 (`vendor/WDL/WDL/zlib/`) and a hand-written stored-only ZIP shape are both rejected and are not to be revisited in this phase | §"The container" | Ε-W1-T1 | -| **Ε-F2** | **Import always lands as a new bank**, with an automatic numeric suffix on a display-name collision — no prompt, no overwrite. Merge-into-existing is **out of scope for Phase Ε**, not deferred | §"Identity and collision on import", incl. the auto-suffix rule | Ε-W2-T2 | -| **Ε-F3** | **Refuse** the import when the tracking ledger is degraded. No confirm-and-proceed path, no opt-out | §"Import under a degraded tracking ledger" | Ε-W2-T2 | - -**No Ε track is gated on a decision.** Every track in this phase is dispatchable as -written. - -### Phase-Ε acceptance criteria - -These bind every track in this phase, in addition to the plan-wide set above. - -- **Byte-exact round-trip is the phase's trust anchor.** Export → import → export yields - byte-identical payloads, and the `hashBytes` digest of every landed file equals the digest - recorded at export. Frame count, sample rate, bit depth, and channel count are untouched on - both sides. **No re-encode anywhere:** `wav_codec` may be called to hash and to read - metadata already recorded, never to rebuild, trim, normalize, or collapse. The mono collapse - is a capture-path behaviour and must not reach the import path — the same exclusion ingest - already carries (root `CLAUDE.md`, exact-bounds invariant). -- **The format cannot express a path.** Manifest entries are bare file names — no directory - component, no `..`, no drive letter, no leading separator — validated on encode *and* - decode. Relative-paths-only becomes structural rather than remembered, and the - archive-traversal bug class closes by construction. -- **Import places no timeline item.** Capture and placement stay separate acts; import is a - capture-shaped act, not a placement one. -- **Import never overwrites and never deletes an existing bank-folder file.** The one - deletion path is the rollback of files *this call wrote* that no index ever referenced — - the documented carve-out at `src/shell/persist/prune_fs.cpp:5-11`, which every track - touching it must **cite, not restate**. -- **Export is read-only against the project.** No ext-state write, no `bumpBankGeneration()`, - no undo point. Import does the opposite: it bumps the generation - (`src/shell/persist/session.h:108`) so live ReaSampler 9000 instances reload, and batches - its index mutation into one Ctrl-Z through `persistBankOp`. -- **All-or-nothing on both sides.** No partial export, no partial import. A truncated - `.rsbank` must never exist on disk (temp file + atomic rename, the Ψ-W2-T2 precedent); a - half-imported bank must never exist in the index (rollback). -- **At most one entry's payload in memory at a time**, on both paths. The pure codec owns - framing and offset arithmetic; the shell owns the stream. A whole-package - `vector` on either side is a rejected shape, not an optimization opportunity. -- **The pure planners take value inputs, never a handle.** The decoded manifest, the - destination `BankBook`, and the set of names present in the bank folder cross the seam as - values. No `ReaSamplerSession&`, no service container, no "pass the thing that has - everything" reaches `core/package/`. If a circular dependency appears during the build, the - fix is a service split or a thin interface — **not** parameter propagation, and **not** a - base class gaining a dependency that grows its subclasses' constructors. -- **Every pure module gets a `_tests` target** that runs without REAPER or a DAW. - The whole collision/version rule set is expressible as pure functions over strings and - hashes; if a rule can only be tested through the shell, the seam is in the wrong place. - -**Performance posture.** Every surface in this phase is cold — per-gesture, once. None of -the named hot paths (peaks envelope compute, audition, realtime-capture tick, instrument -`process()`) is touched by any track here. The one performance fact that *is* load-bearing is -the memory criterion above, and it is stated as a structural constraint rather than a -guardrail because exceeding it does not slow the feature down, it makes it fail. - -**Concurrency with Γ and Λ.** Phase Ε is extension-side and lands almost entirely in **two -new directories** (`src/core/package/`, `src/shell/package/`) that no other phase touches. -Γ lives in `core/instrument/` + `shell/instrument/`; Λ is being specced concurrently and is -not read here. The only pre-existing files any Ε track edits are named per track below — -`core/tracking/origin_ledger` (W1-T3, exclusively), the root `CMakeLists.txt` -`add_subdirectory` list (W1-T1 and W1-T2, one line each), `src/app/main.cpp` and the panel's -bank menu (W2-T1 and W2-T2, one registration line and one menu row each), and -`core/model/bank_book.{h,cpp}` (W2-T2 only — **one additive public `const` member**, required -by the Ε-F2 auto-suffix rule so the name fold keeps its single home). **No Ε track -touches `core/instrument/`, `shell/instrument/`, or any capture backend.** - ---- - -### Ε-W1 — The contract, the filesystem, and the ledger's new kind - -**Depends on:** nothing in this phase. **Three tracks, disjoint by directory** — the split is -by *what each track's inputs are*, which is why they genuinely parallelize: T1 knows only -bytes and structs, T2 knows only paths and bytes, T3 knows only the ledger. - -| Track | Owns | -|---|---| -| **T1** `package-format` | the whole of the new `src/core/package/` **except** `export_plan` / `import_plan` (W2's), plus its `CMakeLists.txt` and `CLAUDE.md` | -| **T2** `package-fs-shell` | the whole of the new `src/shell/package/` **except** `export_bank` / `import_bank` (W2's), plus its `CMakeLists.txt` and `CLAUDE.md` | -| **T3** `import-origin-kind` | `src/core/tracking/origin_ledger.{h,cpp}` and its tests, exclusively | - -**One shared file in the wave, named rather than discovered at merge:** the root -`CMakeLists.txt` `add_subdirectory` list — T1 appends `src/core/package`, T2 appends -`src/shell/package`. Two append-only lines in one list: **textual merge adjacency, not -semantic contention.** Whichever lands second rebases. - -**All three tracks are dispatchable now.** T1 carries the Ε-F1 ruling — the container is the -hand-rolled `RSBK`, decided, not a candidate T1 chooses among. T2 and T3 never depended on -that ruling anyway: T2's API is bytes-in/bytes-out regardless of what those bytes mean, and -T3 touches no package code at all. - -#### Ε-W1-T1 — `package-format` - -**Goal.** The container and its version ladder, entirely pure — the contract every later -track consumes, landed once so nothing downstream re-litigates the shape. - -**Spec:** `docs/product/bank-package.md` §"The container", §"Version tagging", §"What a -package carries", §"What a package deliberately does NOT carry", §"Memory". - -**Surface boundary — owns:** new `src/core/package/package_format` (the magic, the header -layout, `kPackageFormatVersion`, `kPackageMinReaderVersion`, and -`classifyPackageVersion(formatVersion, minReader) -> Readable | TooNew | Malformed`), new -`src/core/package/package_manifest` (the manifest model + its JSON codec), new -`src/core/package/bank_package` (header encode, prefix decode, entry-layout arithmetic), the -directory's `CMakeLists.txt` and `CLAUDE.md`, and one appended `add_subdirectory` line in the -root `CMakeLists.txt`. **Does not own:** `export_plan` / `import_plan` (Ε-W2), anything under -`shell/`, `core/model`, or `core/tracking`. - -**Behavior.** -- **The container is the hand-rolled `RSBK` (Ε-F1, ruled).** Magic `RSBK`, a fixed - little-endian header carrying the two version fields, a length-prefixed JSON manifest, then - each entry's payload concatenated in manifest order. Framing is built on `core/wire/bytes.h` - (`putLE` / `ByteReader`) and the manifest on `core/json` — both already owned and tested - here. **No ZIP, no compressor, no new third-party source in the build**; a link edge to - `vendor/WDL/WDL/zlib/` means the ruling was misread. -- **Two version integers, not one.** `formatVersion` = what this writer emitted; - `minReaderVersion` = the oldest reader that can read it safely. The reader's whole rule is - `minReaderVersion <= kPackageFormatVersion`. An **additive** change (a new optional - manifest key, a new `Sample` field with a defined absent-value) bumps `formatVersion` - only; a **structural** change bumps both. **Growing a persisted enum's vocabulary is - structural here, not additive** — `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 rides inside the nested `BankModel` blob, so a new - `SourceMode` or `Tier` value bumps both integers. The header carries the writer's semver - (`version::stampVersion()`) alongside them, informational, so a refusal message can name - what to install. -- **The ladder is documented the way `origin_ledger.cpp:8-21` documents its own** — a header - comment listing every shipped version and what changed, with the read-and-validate rule - stated, not implied. -- **Unknown manifest keys are skipped** (the `bank_book_json.cpp:182` behaviour), and - **unknown persisted enum integers — the manifest's own, not `BankModel`'s nested ones, which - reject per the bullet above — degrade to their defined `Unknown` equivalent**, never to - the numeric default and never to a parse failure (`core/wire/CLAUDE.md`'s `BakeStatus` rule, - verbatim). Both are pinned by tests, not left to inheritance. -- **The manifest nests `BankModel`'s own serialization verbatim**, exactly as - `bank_book_json.cpp:15-20` nests it, so per-sample shape has one owner and a future - `Sample` field reaches packages for free. Per entry the manifest adds only: the bare file - name, the byte length, and a `hashBytes` digest (`core/capture/wav_codec.h:143`) — - `hashBytes`, **not** `hashWavContent`, because the latter deliberately skips chunks - (`wav_codec.h:145-151`) and so cannot answer "did these bytes survive." -- **The bank's `slot_map` rides along** — display positions are part of what the user built. -- **Framing only, never a payload.** `bank_package` produces the header bytes and an ordered - `[{ name, offset, length }]` layout; it never holds, copies, or hashes an entry's audio. - Decode is symmetric: prefix in, manifest + layout out. -- **Path expression is structurally impossible.** Entry names are validated to contain no - `/`, `\`, `:`, no leading separator, and no `..` component, on both encode and decode. - -**Acceptance criteria.** -- `decodePackage(encodePackage(x)) == x` over a manifest fixture exercising every field, - including every `Sample` optional in both present and absent states. -- A synthetic header with `minReaderVersion` above this build classifies `TooNew` and **no - manifest is produced** — the decode does not half-succeed. -- A synthetic header with `formatVersion` above this build but `minReaderVersion` at or below - it classifies `Readable`, and its unknown manifest keys are skipped without error. This is - the additive-forward-compatibility claim, and it is the reason the two-integer design - exists; a test that does not exercise it leaves the design unproven. -- Truncated input at every byte offset in a valid package returns `Malformed` — never UB, - never a partial manifest, never a read past the buffer. Hostile-input hardening at the - `bank_model.h:204-206` standard. -- Entry names containing `..`, a separator, or an absolute prefix are rejected on encode - *and* rejected on decode. Both directions, because a package can arrive from anywhere. -- No file in the new directory exceeds ~600 lines; the three-module split above is the - responsibility seam, and a fourth module is preferred over a bisection if one is needed. -- `package_format_tests`, `package_manifest_tests`, `bank_package_tests` all run without - REAPER or a DAW. - -**Open questions.** **No [Daniel] questions — Ε-F1 is RULED** (proprietary `RSBK`), so this -track is dispatchable as written. **[propose at review]** whether `package_format` and -`bank_package` are genuinely two modules or one — the split is proposed on responsibility -grounds (constants and classification vs. offset arithmetic) and may collapse if the -arithmetic turns out to be twenty lines. - -#### Ε-W1-T2 — `package-fs-shell` - -**Goal.** Every filesystem and dialog act the two verbs need, landed behind an API that knows -nothing about what a package contains — so it can be authored, reviewed, and tested in -parallel with the format it will carry. - -**Spec:** `docs/product/bank-package.md` §"Where it lives", §"Failure modes", §"Memory". - -**Surface boundary — owns:** new `src/shell/package/package_io` (read a file's bytes, write -bytes through temp + atomic rename, read one bank file, write one landed file, enumerate the -bank folder's existing names, and the rollback delete), the file-picker seam for both verbs — -**one picker, REAPER's own, on every platform**, so this track carries **no** -`#ifdef _WIN32` / `#else swell/swell.h` split. That split is a real pattern in this codebase -(`src/shell/panel/draw_kit.cpp:11-15`, `src/shell/persist/prune_fs.cpp:35-38`); it is simply -not this track's shape, because REAPER owns the dialog. Also owns the -directory's `CMakeLists.txt` and `CLAUDE.md`, and one appended `add_subdirectory` line in the -root `CMakeLists.txt`. **Does not own:** `export_bank` / `import_bank` (Ε-W2), anything under -`core/`, and — emphatically — `prune_fs`, which stays the deletion authority. - -**Behavior.** -- **Atomic write.** A package is written to a temp path in the destination directory and - renamed on complete success. A failed or interrupted write leaves no `.rsbank` behind. This - is the mono-collapse precedent (Ψ-W2-T2, temp file + atomic rename) applied to a much - larger file. -- **Streaming, both ways.** Append one payload at a time on write; seek and read one payload - at a time on read. The API must make holding the whole package awkward, not merely - discouraged. -- **The rollback delete is the carve-out, cited.** Its TU header cites - `src/shell/persist/prune_fs.cpp:5-11` and states the discriminator it satisfies — this call - created the file, and no index ever referenced it — rather than restating the carve-out's - text. Anything that does not satisfy that discriminator is not this function's business. -- **Both pickers are REAPER's own, and they are symmetric.** `GetUserFileName(int mode, - const char* caption, const char* initial_file_or_path, const char* extension_list, char* - fnOutNeedBig, int fnOutNeedBig_sz)` — **verified**, - `vendor/reaper-sdk/sdk/reaper_plugin_functions.h:3790`, documented at `:3788` — serves - both directions: `mode=0` ("choose a new file") is export's destination picker, `mode=1` - ("existing file") is import's source picker. `extension_list` takes the - `'ReaSampler banks|*.rsbank|All files|*.*'` form, and `initial_file_or_path` may be a bare - `'.rsbank'` to set the default extension. There is **no `#ifdef _WIN32` / SWELL split and - no wide-char round trip here** — REAPER owns the dialog on every platform, so no - `GetSaveFileNameW` and no `BrowseForSaveFile`. `GetUserFileNameForRead` is explicitly - "Superseded, see GetUserFileName" (`:3796`) and is not used. -- **No fallback path.** `src/app/main.cpp:15` defines `REAPERAPI_IMPLEMENT` *without* - `REAPERAPI_MINIMAL`, so the resolver walks the full table — `GetUserFileName` included - (`reaper_plugin_functions.h:9084`) — and `main.cpp:292-293` aborts the extension load if - any single function fails to resolve. No REAPER build that loads this extension can lack - `GetUserFileName`, which makes a fallback unreachable code. -- **No REAPER project state is touched here.** No ext-state read or write, no undo block, no - generation bump; those belong to the verbs in Ε-W2. - -**Acceptance criteria.** -- A write interrupted before completion leaves the destination path absent or holding its - prior contents — never a partial new file. Tested by injecting a failure at the writer seam. -- Reading and writing a multi-entry package never holds more than one entry's payload; the - test asserts against a seam counter, not against a memory measurement. -- The rollback deletes exactly the files it was given and nothing else, and is a no-op on a - path it did not write. -- **There is exactly one picker call site and it is REAPER's.** Both verbs reach - `GetUserFileName` — export with `mode=0`, import with `mode=1` — and no symbol named - `GetSaveFileNameW`, `BrowseForSaveFile`, or `GetUserFileNameForRead` appears anywhere in - `src/shell/package/`, nor any platform `#ifdef` in the picker's TU. Greppable, so it stays - true. `[verify — DAW]` — the picker is not exercised in a live REAPER session by this track. -- No file exceeds ~600 lines; the picker lives in its own TU with its own header. **Not** for - the `drag_out` / `drag_out_win` reason — that precedent isolates a *Win32-only* TU, and - there is no platform split here — but because the picker is the only REAPER-facing part of - an otherwise REAPER-free, unit-tested module: folding it into a shared header would drag - `reaper_plugin_functions.h` into the testable seam's include graph. - -**Open questions.** **[propose at review]** where the `extension_list` and default-extension -strings live — this track's picker TU, or the Ε-W2 verbs that call it. They are user-facing -text, and the verbs own the rest of the user-facing text; the counter-argument is that they -are picker plumbing and only one picker exists. **[verify — DAW]** two things the header does -not answer: (1) whether the `mode=0` picker **appends** an extension when the user types a -bare name — `:3788` documents that `initial_file_or_path` may be `'.rsbank'` "to set the -default extension," but not that the dialog enforces it on return, so the verb may still have -to append `.rsbank` itself; (2) **dialog parenting** — `GetUserFileName`'s signature -(`:3790`) takes no owner window, where the abandoned Win32 `OPENFILENAME` path would have -passed `GetMainHwnd()`, so modality against the REAPER main window is unobserved. - -#### Ε-W1-T3 — `import-origin-kind` - -**Goal.** Give the ledger a birth-record kind for a package import, so an imported file is -tracked from the moment it lands rather than becoming a permanently unreclaimable foreign -file — landed as its own track, with its own review, because it edits safety-critical -territory that nothing else in this phase touches. - -**Spec:** `docs/product/bank-package.md` §"What a package deliberately does NOT carry" (the -origin-ledger bullet); `src/core/tracking/CLAUDE.md` for the invariants it must not weaken. - -**Surface boundary — owns:** `src/core/tracking/origin_ledger.{h,cpp}` and its tests, -exclusively. **Does not own:** `tracking_authority` (no decision changes), `shell/persist`, -or any consumer. - -**Behavior.** -- **Append `OriginKind::PackageImport` as value 5.** Append only — `Capture`=1, `Ingest`=2, - `Recapture`=3, `Resample`=4 keep their integers, per `core/tracking/CLAUDE.md`'s - "PERSISTED INTEGERS — never renumber, only append". -- **A build that does not know value 5 degrades it to `Unknown`**, which is the existing - `kindFromInt` behaviour and is the safe direction: the path is still owned, so still - protected; only the kind detail is lost. This is the *field-vocabulary* rule, and it must - stay distinct from the *document-version* rule right beside it, which blocks - (`origin_ledger.cpp:18-21`). -- **Nothing else changes.** No new field, no version bump, no lineage semantics. A new enum - value in an append-only vocabulary is precisely the change that does **not** need `"v"` to - move, and demonstrating that is part of the point. -- **`recordCreated` needs no signature change** — it already takes an `OriginKind` - (`src/shell/persist/session.h:95`). Confirm that in the same pass; if it turns out - otherwise, that discovery is this track's, not Ε-W2's. - -**Acceptance criteria.** -- A ledger containing a kind-5 record round-trips through serialize/deserialize unchanged. -- A record carrying an *unrecognized* kind integer (6, 99, negative) loads as `Unknown` and - the ledger loads `Loaded`, not `Unreadable` — the vocabulary gap does not halt prune. -- `kLedgerVersion` is **unchanged** at 2, and a test asserts it, so the append-vs-bump - distinction is pinned rather than assumed. -- `pruneProtection`'s output is unchanged for every existing kind — this track alters no - decision. - -**Open questions.** **[propose at review]** whether the kind is named `PackageImport` or -folded onto the existing `Ingest`. The plan's recommendation is a distinct value: `Ingest` -means "the user brought in a file," which is close, but losing the distinction makes a future -"where did this bank come from" question unanswerable, and an appended integer costs nothing. - ---- - -### Ε-W2 — The two verbs - -**Depends on Ε-W1 — all three tracks.** T1 for the format the verbs speak, T2 for every -filesystem act they perform, T3 for the kind their birth records carry. No part of either -verb is authorable against a format that has not settled. - -**Two tracks, disjoint by direction.** They share only the manifest type. The split is real -enough that the plan **pre-split the pure planner into two TUs** (`export_plan` / -`import_plan`) rather than one `package_plan` — that separation exists specifically so these -two tracks do not fight over a file. - -| Track | Owns | -|---|---| -| **T1** `bank-export` | `core/package/export_plan`, `shell/package/export_bank`, `shell/actions/package_export_action` | -| **T2** `bank-import` | `core/package/import_plan`, `shell/package/import_bank`, `shell/actions/package_import_action`, the panel's `.rsbank` drop route | - -**Two shared files, named — and the disjointness here is CONDITIONAL, unlike W1's.** -`src/app/main.cpp` (one action-family registration line each) and the panel's bank menu (one -row each). Both are textual adjacency by construction, but Phase Ψ set the precedent of -granting `main.cpp` to a single track rather than sharing it (Ψ-W1-T3). **If the dispatcher -wants zero contention, serialize T2 behind T1** — T2 is the larger track and loses nothing by -starting second. The plan's default is to run them in parallel and rebase whichever lands -second. - -#### Ε-W2-T1 — `bank-export` - -**Goal.** One bank leaves the project as one file, or the export refuses and says why. - -**Spec:** `docs/product/bank-package.md` §"Failure modes" (export rows), §"What a package -carries". - -**Surface boundary — owns:** new `core/package/export_plan` (pure: which entries, what -names, what is missing, and therefore whether the export may proceed), new -`shell/package/export_bank` (the promptless verb — takes a `ReaSamplerSession&`, returns an -outcome, **no prompts and no message boxes**, mirroring `src/shell/bank_ops/`), new -`shell/actions/package_export_action` (the bindable-action skin, mirroring `prune_action`), -one registration line in `src/app/main.cpp`, one panel menu row. **Does not own:** anything -on the import side, `bank_ops`, or `persist`. - -**Behavior.** -- **The exported unit is one bank** — the pool included, since the pool is structurally a bank - (`core/model/CLAUDE.md`'s pool-privileges section). Whole-book export is an explicit - non-goal of this phase and is preserved as an additive future by the manifest's shape, not - by a promise. -- **Refuse-if-incomplete, report-before-acting.** An index entry whose file is missing or - unreadable stops the export by default; the "export the N present entries" path exists only - behind an explicit confirm that lists what is absent, distinguishing missing from - unreadable. This is prune's dry-run-then-confirm discipline applied to a non-destructive - act, and it is deliberate: a silently-incomplete package is discovered on the far side, in - another project, weeks later. -- **The project is not touched.** No ext-state write, no generation bump, no undo point. An - export that mutates project state is a defect, and the acceptance criteria name it as one. -- **Nothing is re-encoded.** Payload bytes are copied and hashed. `wav_codec` is not asked to - rebuild anything. -- **A new FOREVER-STABLE command id** is minted through `version::channelCommandId(suffix)` - per the root `CLAUDE.md` action contract, with its per-channel display name through - `channelActionName`. - -**Acceptance criteria.** -- `planExport` is pure and total over its inputs: a bank with a missing file, an unreadable - file, zero samples, and one sample all classify without touching a filesystem. -- Exporting a bank and re-reading the package yields, for every entry, a `hashBytes` digest - equal to the source file's — asserted per entry, not in aggregate. -- The exported manifest contains no absolute path and no path separator, asserted by a test - that scans the emitted bytes rather than by inspecting the model. -- A failure injected mid-write leaves no `.rsbank` at the destination and the prior file, if - any, intact. -- Project ext state is byte-identical before and after an export, and `bankGeneration()` is - unchanged — a direct assertion, because "we did not mean to write anything" is not a - property that survives without one. -- Exporting an empty bank produces a valid, importable package with zero entries rather than - refusing. An empty bank is a legitimate thing to carry. - -**Open questions.** **[propose at review]** whether the export affordance is action-only, -panel-only, or both at ship. **[propose at review]** whether the default file name is derived -from the bank's display name (recommended, sanitized through -`capture_paths::sanitizeStem`) or from the project name. - -#### Ε-W2-T2 — `bank-import` - -**Goal.** A package becomes a **new** bank in this project — completely, or not at all — -with every one of the four collision classes answered explicitly rather than by whatever the -model happens to do. - -**Spec:** `docs/product/bank-package.md` §"Identity and collision on import" (including the -auto-suffix rule), §"Failure modes" (import rows), §"Import under a degraded tracking -ledger", §"Version tagging: both directions". - -**Surface boundary — owns:** new `core/package/import_plan` (pure: the id remap table, the -parent remap, the per-entry write / skip-already-present / rename disposition, the -destination bank name after uniqueness folding), new `shell/package/import_bank` (the -promptless verb), new `shell/actions/package_import_action`, the panel's `WM_DROPFILES` route -for a `.rsbank` (routing only — the existing ingest route for audio files is untouched), one -registration line in `src/app/main.cpp`, one panel menu row, and — the **only** `core/model/` -edit in the phase — **one additive public `const` member on `BankBook`** (recommended -`std::string uniqueDisplayName(const std::string& seed) const`), so the auto-suffix probe runs -behind the model's own name fold. **Does not own:** anything on the export side, -`bank_book`'s *rules* (consumed, never re-implemented — the new member exposes the existing -fold, it does not add a second one), `origin_ledger` (Ε-W1-T3's). - -**Behavior.** -- **The ledger guard runs FIRST — before the file picker opens (Ε-F3, ruled: refuse).** If - `tracking::ledgerDegraded(status)` holds for the project's loaded ledger status - (`Unreadable` or `FutureVersion`; `core/tracking/origin_ledger.h:94`, `:100-101`), the - import **refuses outright** — no picker, no bytes read, no confirm-and-proceed path, no - opt-out. `Fresh` and `Loaded` both proceed. **Do not key this on - `PruneReport::blockedByTracking`**: that flag also fires on undecodable `rsusage_*` keys, - which govern deletion-time protection and have nothing to do with writing birth records. - The refusal is a `ShowConsoleMsg` block mirroring `prune_action.cpp:30-69` in structure and - tone, with two variants (malformed / newer-build) and every recovery line naming **this - build's** namespace through `version::extStateNamespace()`. Exact wording in the spec doc. - **Export is deliberately not gated this way** — that is Ε-W2-T1's, and it stays ungated. -- **Version gate second, before any byte is written.** `minReaderVersion` above this build - refuses the whole package and reports through `ShowMessageBox` - (**verified**, `reaper_plugin_functions.h:6546`) naming three things: the package's - requirement, this build's ceiling, and the writer's semver. Two of the three is not enough - to act on. A malformed or truncated package reports **distinctly** — the two failures have - opposite recoveries, which is exactly why `origin_ledger.cpp:178-185` separates them. -- **Four collisions, four answers.** (1) **Sample id** — remint every id and remap - `Provenance::parentSampleId` (`bank_model.h:45-50`) through the same map, to the reminted - parent when it came in the same package and cleared otherwise; a foreign id never enters - the index. (2) **File name** — never overwrite; mint a fresh unique name through - `capture_paths::deriveBankPaths`, silently, counted in the summary. (3) **Content hash** — - consult `BankModel::findByHash` **before writing the payload**; on a hit, skip the write - entirely and let the entry collapse, so a dedup never manufactures an orphan. (4) **Bank - display name** — **auto-suffix, no prompt** (Ε-F2, ruled). Seed = the package's recorded - source bank name **verbatim** (or the literal `Imported bank` if absent/blank); take the - first of `seed`, `seed + " 2"`, `seed + " 3"`, … whose fold is free in the destination - book, ascending from 2. Four points that decide the behaviour and must not be re-invented: - the seed is **never re-parsed** (`"Drums 2"` colliding lands as `"Drums 2 2"`, not - `"Drums 3"` — a bare trailing integer is indistinguishable from `"Kit 808"`); the probe - **fills gaps** (first-free, not highest-plus-one, so it is a pure function of the current - name set); the probe **terminates** by pigeonhole within `B + 1` candidates for `B` banks, - so **no arbitrary cap**; and the fold is `BankBook`'s own (`bank_book.h:252-258`), reached - through the new public member, never re-implemented in `import_plan`. Sample display names - are **not** suffixed, and `slot_map` positions are untouched. -- **Always a new bank; never a merge (Ε-F2, ruled).** The import creates a bank — it never - merges into an existing one, never lands into the pool, and offers no target picker. A - pool export therefore lands as a **named** bank `"Pool 2"`, which is correct, not a glitch. - This track ships **one** action, not two; merge-into-existing is out of scope for the - phase, and move/copy already cover the after-the-fact case. -- **Birth records at landing.** Every landed file goes through - `ReaSamplerSession::recordCreated(sample, OriginKind::PackageImport)` at the same point the - `Sample` is added, in the same straight-line block, per `core/tracking/CLAUDE.md`'s - no-silent-gaps invariant. An import that lands a file without a record is the exact failure - that section exists to prevent. -- **All-or-nothing, with rollback.** Any failure after the first write deletes the files - *this call wrote* and abandons the index mutation. The rollback cites the - `prune_fs.cpp:5-11` carve-out; it does not restate it, and it does not reach outside the set - it wrote. -- **One Ctrl-Z for the index, and the file residue is stated, not implied.** The index - mutation batches through `persistBankOp` (`Undo_BeginBlock2` / `Undo_EndBlock2`, verified at - `reaper_plugin_functions.h:7758` / `:7806`). Undo does **not** un-write the files; they - remain as orphans until a prune reclaims them — the same designed window a non-empty bank - delete already produces (`core/model/CLAUDE.md`'s sample-removal section). The user-facing - summary says so. -- **`bumpBankGeneration()` on success** (`session.h:108`), so live instances reload. -- **No timeline item is placed. Ever.** -- **A new FOREVER-STABLE command id**, minted the same way T1's is. - -**Acceptance criteria.** -- `planImport` is pure and total, and every one of the four collision classes has a test that - exercises it without a filesystem: colliding ids, colliding file names, a hash already - present, and a colliding bank name. -- Importing a package built from bank B back into the project that already contains B lands - a **new** bank named `"B 2"`, with every id reminted, no entry lost, and B itself - unmutated. Importing it a third time lands `"B 3"`. -- The suffix probe is pinned by pure tests over a name set, covering at minimum: a free seed - (no suffix applied), a case/whitespace-folded collision (`"drums"` blocks `"Drums"`), a gap - (`"Drums"` + `"Drums 3"` present ⇒ `"Drums 2"`), a seed that already ends in a number - (`"Drums 2"` colliding ⇒ `"Drums 2 2"`), an absent/blank recorded name (⇒ `Imported bank`), - and a package whose source bank was the pool (⇒ `"Pool 2"`, a named bank). -- A degraded ledger (`Unreadable` and `FutureVersion`, both asserted) refuses the import with - **no picker shown, zero files written, zero index mutation**, and the message names the - channel-correct ext-state namespace. An undecodable `rsusage_*` key with an otherwise - `Loaded` ledger **does not** block — asserted, because the tempting reuse of - `blockedByTracking` would silently make it. -- An entry whose payload fails its `hashBytes` check aborts the import with **zero** files - landed and **zero** index mutation — asserted on both, since either alone would pass a - weaker test. -- A write failure injected at entry k of n leaves exactly zero files from this import on - disk and the index unchanged. -- Every landed file has a ledger birth record with kind `PackageImport`, asserted by reading - the ledger after the import, not by counting calls. -- `minReaderVersion` above the build: nothing written, message names all three facts. - `formatVersion` above the build with `minReaderVersion` at or below it: **imports cleanly**, - unknown keys skipped. Both directions asserted, in this track, against real package bytes. -- No timeline item exists after an import; the arrange is byte-identical. -- **DAW-verification obligation** (to be discharged by Daniel, not by this track): import a - package produced on another machine, confirm the panel shows every sample with its - metadata, confirm a live ReaSampler 9000 instance picks up the new bank content on the - generation bump, and confirm one Ctrl-Z removes the index entries. - -**Open questions.** **No [Daniel] questions — Ε-F2 and Ε-F3 are both RULED** (new bank -always with an auto suffix; refuse on a degraded ledger), so this track is dispatchable as -written and ships one action. **[propose at review]** whether the import summary is a console -block, a message box, or both; the recommendation is a console block plus a one-line message -box, so the detail is copyable and the outcome is unmissable. Note the refusal path is -already fixed at a console block by the Ε-F3 spec, so this call is about the *success* -summary only. - ---- - -### Ε-W3 — The compatibility fixtures - -**Depends on Ε-W2 for:** both verbs existing. A round-trip claim cannot be tested against one -half of a round trip, and a "this build refuses a future package" claim cannot be tested -against a package this build is incapable of writing. - -**One track.** The whole deliverable is one corpus and the harness over it; splitting it -would mean two tracks writing two halves of one fixture set. - -#### Ε-W3-T1 — `package-compat-fixtures` - -**Goal.** Turn the version-compatibility policy from an assertion in a doc into a property -proven against **frozen bytes**, so a later format change cannot silently break either -direction. - -**Spec:** `docs/product/bank-package.md` §"Version tagging: both directions". - -**Surface boundary — owns:** a new checked-in fixture corpus under the package modules' test -tree, the harness that decodes it, and one `docs/` verification script for the DAW half. -**Does not own:** any production module — if a fixture reveals a defect, the fix is filed -against the owning track's module and this track carries the failing test, not the patch. - -**Behavior.** -- **Frozen bytes, not regenerated ones.** The corpus holds real `.rsbank` bytes committed to - the repo: a v1 package written by the shipping build, a synthetic - `formatVersion` = N+1 / `minReaderVersion` = current package (the additive-forward case), - and a synthetic `formatVersion` = N+1 / `minReaderVersion` = N+1 package (the refuse case). - A test that regenerates its own fixture proves only that the code agrees with itself — - which is precisely the failure mode a format ladder exists to catch. -- **A truncation corpus.** The valid package truncated at a spread of offsets, each asserted - `Malformed` rather than `TooNew`, so the two recoveries never get crossed. -- **A hostile-name corpus.** Packages whose entry names carry `..`, separators, and absolute - prefixes, each refused. -- **The round-trip anchor.** Export → import → export over the v1 fixture yields - byte-identical payloads. -- **A standalone DAW verification script** in `docs/`, following the - `docs/verify-track-scope-multitrack.md` precedent — the cross-machine transfer is the one - claim no unit test can make. -- **The corpus is append-only.** When a future format version ships, its fixture is added; - no existing fixture is ever regenerated or edited. Stated in the corpus's own README so the - rule survives the person who wrote it. - -**Acceptance criteria.** -- All three version fixtures classify as specified, and the additive-forward one imports with - every known field intact and every unknown key skipped. -- Every truncation offset classifies `Malformed`; none classifies `TooNew`, `Readable`, or - crashes. -- Every hostile-name fixture is refused at decode, before any planner runs. -- The round-trip fixture's payloads are byte-identical after export → import → export. -- The DAW script exists and names its steps concretely enough to run without reading this - plan. - -**Open questions.** **[propose at review]** how large the committed corpus is allowed to be — -the recommendation is one-sample packages with a few hundred bytes of payload each, since the -properties under test are structural and a large payload proves nothing extra. - ---- - -## Phase Ρ — Render in place: a track's output to a new sibling, source to the bench - -**Ships:** one bindable action that renders the selected track's output over the current -range to a file outside the bank, drops that file as an item on a brand-new sibling track -at the exact position it was rendered from, clones the source's colour and its name with an -idempotent `Capture ` prefix, moves the source track into Design mode — where Design -View's existing park hides it, takes it out of the mix, and puts its FX offline — and puts -the result track into Arrange, unconditionally. The bank is never read, never written, and -never notified. - -**Consolidates: none of the seventeen.** Phase Ρ came from a direct request (Daniel, -2026-08-02) and is scoped in `docs/product/render-in-place.md`. Daniel's framing, verbatim -in substance: *similar to REAPER's "Render selected track time selection to new track -(stereo) and mute original", except instead of muting the original, the source track -stays/goes to design mode, and the resulting new sibling track — which gets the rendered -audio item placed correctly in the timeline — stays in whatever mode was active when the -action was run; the new track clones the source track colour and name with a Capture -prefix; this must NOT put the rendered audio into the ReaSampler banks/pool.* **The -mode-following clause in that framing was superseded by Daniel's own Ρ-F2 ruling the same -day** — *"for this action which is not a capture, the result track should always go to -arrange"* — and the quote is kept verbatim only as the record of the request. It -**supersedes nothing** — a sweep of `docs/TODO.md` and `docs/TODO-1.0.md` for -`render.in.place|render to new track|preserve.source` returns nothing. - -### The framing answer — why this does not breach the load-bearing principle - -Root `CLAUDE.md`'s rule — *capture and placement are separate acts; any code path that -auto-inserts a capture into the timeline must be rejected in review* — is a rule **about -the bank**, and Phase Ρ does not put the bank on either side of its verb. The full argument -is `docs/product/render-in-place.md` §"The third verb"; the operative summary, which every -review of this phase must apply: - -| Verb | Source | Sink | Touches the bank | -|---|---|---|---| -| **Capture** (`RunCapture`, batch, realtime, bake, ingest) | arrange / instrument | bank | writes it | -| **Placement** (`RunInsertSelected`, `performArrangeDrop`) | bank | arrange | reads it | -| **Render in place** (this phase) | arrange | arrange | never | - -What Ρ shares with capture is the **render** — `renderOffline`, `FxBypassGuard`, exact -custom time bounds, `RENDER_ADDTOPROJ = 0`, the multi-track refusal — not the capture. A -capture is a render *plus* a bank landing; Ρ takes the mechanism and declines the landing. - -**Four boundary conditions, three of them structural, all review-rejectable:** - -1. Ρ's shell never calls `session.bank()`, `session.book()`, `session.recordCreated()`, or - `session.bumpBankGeneration()`. The `Sample` the backend returns is discarded, and on the - `ProjectMedia` destination its `relativePath` is left **empty** — a Ρ `Sample` is inert - by construction. -2. **Ρ cannot express "write into the bank folder."** The destination reaches the backend as - a two-valued enum, never a caller-supplied path string. A `renderDir` string on - `CaptureRequest` instead of the enum **is** the drift, and is rejected on sight. -3. Ρ's file is never recorded as owned, so prune (`(owned ∩ present) − referenced`) cannot - reach it — and it lives outside the bank folder, so prune's enumeration never sees it - either. Two independent layers. The tool deletes only what it owns; a render-in-place - file belongs to the project. -4. **Traffic is one-way.** Ρ may borrow capture's render; **capture may never borrow Ρ's - placement.** No `place` flag on `CaptureActionDef`, no "capture and also place" action, - ever. - -### Phase-Ρ acceptance criteria - -These bind the track in this phase, in addition to the plan-wide set above. - -- **Placement is sample-exact and unsnapped.** The item lands at the render window's - `startSeconds`, unrounded, with `SnapToGrid` deliberately **not** applied (unlike - `performArrangeDrop`). Ρ's placement is the null test performed automatically — a render - of a range re-inserted at its source position nulls to silence against the source — so a - snapped or rounded placement is a phase failure, not a rough edge. -- **No tempo conform, ever.** `computeInsertMode(InsertOptions{})` only; `insert_plan` - already guarantees the &4 stretch-to-time-selection bit is never set. No conform variant - of this action is offered. -- **The bank path is byte-identical to today.** The `CaptureDestination` enum defaults to - `Bank`; every existing capture entry point must produce exactly the file, path, hash, and - index entry it produces now. If any capture test changes expectation, the seam is wrong. -- **The new track is bare.** `InsertTrackInProject(proj, p, /*flags=*/0)` — flags&1 adds - default envelopes/FX (SDK header 3954) and a default chain would process the render a - second time. -- **Both `I_FOLDERDEPTH` writes, or none.** The sibling-placement arithmetic is pure and - unit-tested before any DAW work; a render that lands the new track at the wrong nesting - level is audibly wrong in both directions (double-processed through a folder it re-enters, - or bypassing the folder bus entirely). -- **The Ρ-F2 ruling survives the auto-tag detector.** The result track and its placed - item are tagged `kArrangeModeId` **explicitly** (a membership record, not an `untag()` - to the Arrange default), and `panel_input::detectNewContent` drops added GUIDs that - already carry a record. Without that filter the detector tags every new track to the - active mode on its next tick and a Design-fired render silently becomes a Design - member — the ruling reversed inside a second. This criterion **replaces** the - tag-before-reapply ordering criterion the phase carried under mode-following: with the - result track an Arrange member unconditionally, a Design reapply parking it is the - correct outcome and the ordering is state hygiene, not behaviour. -- **One undo block, `UNDO_STATE_ALL`,** opened before the track is created and closed after - the mode reapply; the render sits outside it. `persistViewState` runs **after** the block - closes — it may raise a Save-As dialog, which must not sit inside an open undo block - (`design_view_actions::doMoveItems`' documented ordering). -- **Every pure module gets a `_tests` target.** All three pure additions land in - existing `core/capture` modules that already have one. - -**Performance posture.** Every surface is cold — one gesture, once. None of the named hot -paths (peaks envelope compute, audition, the realtime-capture tick's single-pointer-test -idle fast path, the instrument's `process()`) is touched. - -**Concurrency.** Phase Ρ is extension-side. Γ lives in `core/instrument/` + -`shell/instrument/`; Ε lands in the new `core/package/` + `shell/package/`. The pre-existing -files Ρ edits are named in its track's surface boundary and intersect neither — including -`shell/panel/panel_input.cpp`, which the Ρ-F2 ruling adds: no in-flight track in this plan -touches that file (Phase Ψ's two named regions in it, the footer block and the drag-arm -block, are both landed, and both are functions other than `detectNewContent`). - -### Rulings — Daniel's, 2026-08-02. Nothing open. - -All three forks this phase opened were ruled the day it was framed. Full statements, the -counter-arguments that made each a fork, and the one unexercised alternative: -`docs/product/render-in-place.md` §"Rulings". - -- **Ρ-F1 — multi-track. RULED: refuse.** One selected track per fire, inherited from - `isMultiTrackStemRender`. A **settled non-goal**, in the same register as the other - entries under §"What Phase Ρ explicitly is NOT" — not deferred-with-a-plan. There is no - per-track loop planned, no second wave holding one, and no seam to leave half-open for - it. Matches the framing recommendation; nothing in the track changed. -- **Ρ-F2 — the result track's mode. RULED: always Arrange.** *"For this action which is - not a capture, the result track should always go to arrange."* **This overrode the - framing and the request's own original wording** — the result track no longer follows - the active mode. The source still goes to Design. Three consequences, all specced - below: the result track and its item are tagged `kArrangeModeId` explicitly; firing - from Design produces **no visible change** (the A/B-on-the-bench behaviour - mode-following would have enabled **does not exist** and must not be cited as a - benefit anywhere); and the panel's auto-tag detector needs a two-line - explicit-tag-wins filter, or it re-tags the result track to Design on its next timer - tick and silently reverses the ruling. -- **Ρ-F3 — tail. RULED: follow the panel tail settings.** Matches the framing - recommendation; nothing in the track changed. **Two consequences, accepted rather than - caveated:** under Auto/Manual the placed item is **longer than the window it replaces** - (correct for a decaying chain, wrong for a butt-joined section — the user's lever is - the panel's own tail setting), and the exact-bounds gate is **inactive** in those two - modes because it runs only under `TailMode::None`. Both are inherited from every other - capture path, not introduced here. The item's **start** is exact in all three modes, so - the null test holds in all three. The third option floated at framing (run the gate's - start-alignment check regardless of tail mode) is **not ruled in** and is recorded in - the product doc as an unexercised alternative. - ---- - -### Ρ-W1 — The verb - -**Depends on:** nothing. - -**One track, deliberately.** The whole phase is roughly 350 lines: three small pure -additions to existing `core/capture` modules, one bounded edit to the offline backend, one -new shell TU, a two-line filter in `panel_input.cpp`, one `ActionTableRow`, four invariant -amendments. Splitting it would create a -merge dance across `core/capture` for no gain, and the pure half cannot be reviewed -meaningfully apart from the caller that gives it meaning. **Named contingency:** if the -sibling-placement arithmetic balloons in implementation, that function is the natural split -point — it is the only piece with zero dependency on anything else in the track. - -#### Ρ-W1-T1 — `render-in-place` - -**Goal.** One bindable action: 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 render position, -clone colour and name, move the source to Design and the result track to Arrange — -**always Arrange, whatever mode was active** (Ρ-F2). - -**Spec:** `docs/product/render-in-place.md` — §"The render", §"Where the file goes", -§"Placement", §"The new track", §"Mode transitions", §"Undo", §"The action". - -**Surface boundary — owns:** -- **New:** `src/shell/capture/render_in_place.{h,cpp}` (the action body) and its - `CMakeLists.txt` entry. It lives in `shell/capture/` rather than `shell/actions/` because - it composes `renderOffline` and `ResolveScopeSource` — that directory's `CLAUDE.md` places - action *bodies* here and reserves `shell/actions` for skins over mutation logic owned - elsewhere. -- **Extends (existing modules, existing test targets):** `core/capture/track_topology` - (`siblingPlacement`), `core/capture/capture_name` (`captureTrackName`), - `core/capture/capture_paths` (`RenderPaths` + `deriveRenderPaths`, with `deriveBankPaths` - re-expressed over it so the file-stem spelling keeps one owner — - `bankRelativeForName` already depends on that). -- **Edits, bounded:** `src/shell/capture/capture.{h,cpp}` — the `CaptureDestination` enum on - `CaptureRequest`, the ~6-line destination branch at path derivation, and - `CaptureResult::absolutePath`. `src/shell/panel/panel_input.cpp` — two lines inside - `detectNewContent` only, dropping added GUIDs that already carry a membership record - (`MembershipIndex::query(guid) != nullptr`) before the `autoTagNewContent` call. That - edit exists **only because of the Ρ-F2 ruling**. `src/app/main.cpp` — one - `ActionTableRow`. -- **Does not own:** anything under `core/instrument/`, `shell/instrument/`, `core/package/`, - `shell/package/`, `core/model/`, `core/tracking/`, `core/reclaim/`, or `shell/persist/`. - No new directory. No new persisted state, no new ext-state key, no new wire version rung. - -**Behavior.** -- **Resolve** via `ResolveScopeSource(CaptureScope::Track, …)` — razor-else-time range, - selected tracks, canonical GUIDs, source track name. No range → refuse with the reason - `resolveRange` produced. **Item extent is not a fallback and must not become one.** -- **Refuse multi-track by inheritance.** `renderOffline` fires `isMultiTrackStemRender` - before touching anything, keyed on the render *source* (`SelectedTracks`), so Ρ adds no - check of its own and gets `multiTrackRefusalMessage(CaptureScope::Track)` for free. -- **Render** through `renderOffline(CaptureScope::Track, src.sourceTracks, req)` with - `req.destination = ProjectMedia`, tail from `bankPanelTailSetting()`, `channelCount = 2`, - `Float32`, `sampleRate = 0`, and the name from `captureNameFor(src.trackNames, 0, - "capture")`. Inherits the FX-bypass guard, the render-selection guard, the `RENDER_*` - snapshot/restore, `RENDER_ADDTOPROJ = 0`, the exact-bounds gate, the unsaved-project - Save-As gate, and the lossless mono collapse — **all unchanged**. -- **Destination resolves in the backend, after its own save gate**, so an unsaved project is - still prompted before any path arithmetic runs. `ProjectMedia` → `GetProjectPathEx(proj, - …)` (SDK header 2550; header 3102 names it as the way to get the *effective* recording - path when `RECORD_PATH` is blank or relative). The relative-paths-only invariant is - untouched — it binds the `BankIndex`, and Ρ writes to no index. -- **Sibling placement** (pure, in `track_topology`): prefix-sum `I_FOLDERDEPTH` to absolute - levels; `L = level[srcIdx]`; if `depth[srcIdx] >= 1` the insert position `p` is the first - `j > srcIdx` with `level[j] == L` (else `count`), otherwise `p = srcIdx + 1`; then exactly - two writes — `depth[p-1] = L - level[p-1]` and the new track's `depth = level[p] - L` - (with `level[count] = 0`). Total delta sum preserved, so nothing downstream shifts. The - five cases and their expected outcomes are tabulated in the product doc; a malformed - project whose deltas do not sum to zero clamps rather than asserts. -- **Create + dress:** `InsertTrackInProject(proj, p, 0)`, `GetTrack(proj, p)`; - `SetMediaTrackInfo_Value(new, "I_CUSTOMCOLOR", (double)GetTrackColor(source))` — one line - clones a colour and the absence of one, since `GetTrackColor` returns the value already - OR'd with `0x1000000` and `0` means unset; `GetSetMediaTrackInfo_String(new, "P_NAME", - buf, true)` with `captureTrackName(trackName(source))`. -- **Name rule (pure, tested):** `"Capture " + sourceName`, **idempotent** — if the source - name already begins with the prefix, the new name is the source name verbatim, so a second - run yields `Capture MONEY`, never `Capture Capture MONEY`. An unnamed source yields - `Capture Track N` (`trackName` uses `GetTrackName`, which already answers REAPER's own - convention — the Ψ-W2-T1 precedent). A counter suffix is rejected: REAPER does not - uniquify track names either, and it is a treadmill. -- **Place:** snapshot the edit cursor → `SetOnlyTrackSelected(new)` → - `SetEditCurPos(src.startSeconds, false, false)` → `InsertMedia(absolutePath, - computeInsertMode(InsertOptions{}))` → restore the cursor. **Leave the new track selected, - alone** — a deliberate divergence from the restore-the-selection convention every other - placing path follows, because in the headline case the source is being parked out of sight - in the same gesture and restoring the selection would leave the user selecting an - invisible track. -- **Modes (Ρ-F2, RULED — absolute, not mode-following):** - `membership().tag(sourceGuid, kDesignModeId)` (covers stays *and* goes — `tag` replaces - prior single-mode membership); `membership().tag(newTrackGuid, kArrangeModeId)` — - **`kArrangeModeId` unconditionally, never `view.activeModeId()`**; `membership().tag(…, - kArrangeModeId)` for **each item on the new track** (enumerate after `InsertMedia`; the - track is brand new so those are exactly the items just placed — `item_read::itemGuid` is - the existing GUID seam); then `mintManagedLanes(view, nullptr)`; then `applyMode(view, - view.activeModeId(), nullptr)` — a reapply, never a switch, so it is not transport-gated - and does not touch solo. -- **Why explicit tags and not `untag()`:** an untagged GUID is an Arrange member by - behaviour but carries no membership record, and the record is what the auto-tag - detector's new filter keys on. Tag, don't untag. **This is the first explicit - `kArrangeModeId` record in the tree** — the shipped *tag selected tracks → Arrange* - action dispatches to `doUntag()`, i.e. Arrange-by-absence. The record is well-formed - and behaviourally identical (`isMember` answers the same for both states, `untag()` - still clears it); it costs one persisted entry. Unit-test the JSON round-trip; - `[verify — DAW]` that such a project shows no view-behaviour difference. -- **Fired from Design, the result track is parked and nothing is visible.** That is the - ruled behaviour, not a bug: the result track is an Arrange member, so a Design reapply - parks it, and it appears in the source's place on the next switch to Arrange. The - tag-before-reapply ordering that mode-following made load-bearing is now state hygiene - only. -- **Selection stays absolute too.** The result track is left selected, alone, in both - cases — even fired from Design, where that selects a parked track. Making selection - conditional on the active mode would reintroduce exactly the mode-relative behaviour - Ρ-F2 removed. -- **Inherited and deliberately not fought:** show-both on the source is not cleared (it is - the user's pin); a folder-parent source is not hidden by tagging, because parents are - derived (`core/view/CLAUDE.md`) — the existing tag action behaves identically. **Do not - invent a cascade that tags the children.** -- **Feedback:** silent on success (the new track is the feedback), `ShowConsoleMsg` on every - refusal — matching `RunInsertSelected`. **[propose at review]** whether the Design-fired - path breaks that silence with a one-line `ShowConsoleMsg` naming the track it created: - under Ρ-F2 that path produces no visible change, so a silent success is - indistinguishable from a no-op. Recommendation: yes, one line and one string. -- **Action:** suffix **`RENDER_TRACK_IN_PLACE`** — FOREVER-STABLE per channel, minted as a - new `RENDER_*` verb family rather than a `CAPTURE_*` member, deliberately: the id is - permanent and is the most durable statement the codebase makes about which pillar a - feature belongs to. Phrase: **`"render selected track to a new track (source moves to - Design)"`**. Main section only; one `ActionTableRow`; no `custom_action`/`hookcommand2`. - -**Invariant amendments — deliverables of this track, not follow-ups** (the Phase Ψ -precedent, where three such amendments were acceptance criteria of the tracks that broke -them). A track that lands Ρ without these reads as an invariant breach in review. - -1. `src/shell/capture/CLAUDE.md` §Invariants — *"`RunInsertSelected` is the one deliberate - exception to capture-never-places."* Amend to state that this directory now hosts two - placing paths and give the discriminator: `RunInsertSelected` places a *bank sample*; - `render_in_place` places a render that never entered the bank. Neither is a capture - placing itself. -2. `src/shell/actions/CLAUDE.md` §Invariants — *"`arrange_drop_win` is the only - timeline-placing shell in this directory."* Scope the sentence explicitly to that - directory and cross-reference the third verb. -3. Root `CLAUDE.md` §"The load-bearing principle" — **one sentence, not a rewrite**: a - render that never enters the bank and never leaves it is a third verb outside the rule, - with the two-way boundary named. The prohibition must not be softened; the exception must - be named precisely. -4. `src/core/view/CLAUDE.md` §Invariants — *"New tracks are tagged to the active mode at - creation."* **Added by the Ρ-F2 ruling.** That rule now applies only to a GUID carrying - no membership record: an explicit tag wins over the detector. Amend the sentence and - state the reason in one clause — the detector classifies content the *user* made, not - content the tool made and already classified. `src/shell/panel/CLAUDE.md` describes - `panel_input` only as "the new-content auto-tag timer" and does not restate the rule, so - it needs no amendment. - -**Acceptance criteria.** -- `siblingPlacement` is unit-tested over all five cases in the product doc's table (normal - mid-folder, last-in-folder `-1`, last-in-two-folders `-2`, folder parent, last track in - the project) plus a malformed non-zero-sum delta list, with no DAW. -- `captureTrackName` is unit-tested for plain, already-prefixed (idempotence), empty, and - `Track N` sources. -- `deriveRenderPaths` round-trips the same stem spelling `deriveBankPaths` produces for the - same inputs, and the existing `capture_paths` tests pass unchanged. -- Every existing capture test passes with **no expectation change** — the proof that the - destination seam is inert on the `Bank` path. -- `render_in_place.cpp` contains no reference to `session.bank()`, `session.book()`, - `recordCreated`, or `bumpBankGeneration` — checkable by grep, and the review gate for - boundary condition 1. -- The action registers, dispatches, and mirror-unregisters through the single - `ActionTableRow` — no separate registration mechanism. -- `render_in_place.cpp` contains no reference to `activeModeId` in the tagging path — - checkable by grep, and the review gate for the Ρ-F2 ruling. The result track and its - item are tagged `kArrangeModeId`; `activeModeId()` appears only in the `applyMode` - reapply. -- `detectNewContent` no longer auto-tags a GUID that already carries a membership record, - and the existing `view_mode_model` / `guid_diff` tests pass unchanged (the filter is - shell-side; `autoTagNewContent`'s pure contract does not move). -- A membership index carrying an explicit `kArrangeModeId` record JSON-round-trips - unchanged, and `isMember` answers identically for that record and for an absent one — - one added `view_mode_model` test, no DAW. -- All four invariant amendments are in the diff. - -**DAW-verification obligation** (stated up front; nothing past the pure functions is -unit-testable): -- **The null test on Ρ's own output** — render a track over a range, polarity-invert the - source against the new track, confirm silence. The phase's trust anchor. -- **The three folder cases**, each confirming the new track's nesting level and that the - render feeds (or correctly bypasses) the folder bus. `[verify — DAW]` whether - `InsertTrackInProject` plus the two `I_FOLDERDEPTH` writes settle without an intermediate - `TrackList_AdjustWindows(false)` (SDK header 7735; header 2721 notes some attribute writes - need a manual panel update, and the `isMinor` semantics are undocumented). -- **The collapsed-mono placement** — render a dead-centre source, confirm a mono item, and - confirm it sums at the same level the stereo source did. This is root `CLAUDE.md`'s - existing `[verify — DAW]` on mono-item-on-stereo-track summing, **promoted to - load-bearing by this phase**: Ρ is the first path where a collapsed render is placed into - the mix by the tool itself. -- **Both mode transitions under the Ρ-F2 ruling** — fired from Arrange (source parks; - result track visible and in the mix) and fired from Design (source stays on the bench; - result track parked, then present in the source's place after a switch to Arrange). - **In each case wait out at least one panel timer tick and re-check the membership** — - that is the auto-tag-detector regression, and it is what catches a missing - explicit-tag-wins filter or an untagged item, either of which silently reverses the - ruling. The old ordering check (that the new track is never momentarily parked) no - longer applies. -- **Undo** — one Ctrl-Z removes track and item and reverts the folder-depth write; the file - survives (REAPER's undo deletes no files, and prune cannot reach this one); the source - stays tagged Design, whose way out is the existing *tag selected tracks → Arrange* action. - All three residuals are inherited from the documented model-vs-undo split - (`src/shell/view/CLAUDE.md` §Gotchas), not introduced here. -- **Name and colour clone**, including a second run over an already-prefixed track (must not - stack) and an unnamed source (must read `Capture Track N`). -- **`GetProjectPathEx` against a project with a non-default recording path**, confirming the - render lands where the project's media lives. - -**Open questions.** **No [Daniel] questions — Ρ-F1, Ρ-F2 and Ρ-F3 are all RULED** -(2026-08-02), so nothing here is gated. Two **[propose at review]** items, both -recommendation-carrying. First, whether the Design-fired path emits a one-line -`ShowConsoleMsg` (see the Feedback bullet) — recommendation: yes. Second, **[propose at -review]** whether `render_in_place` should refuse when the source track is the master — -`ResolveScopeSource` collects via `CountSelectedTracks`/`GetSelectedTrack`, which skip the -master (SDK header), so a master-only selection already resolves to zero tracks and refuses -with "nothing selected"; the recommendation is to leave that inherited behaviour alone -rather than add a message for a case the existing path already handles correctly. - ---- - ## Phase Λ — ReaSampler on Linux: both artifacts, shipped **Ships:** `reaper_reasampler.so` and `reasampler_9000.vst3` built, installed and documented @@ -3196,7 +1692,9 @@ All textual adjacency, not semantic contention, unless marked otherwise. ## Traceability — all seventeen items -The check that nothing was dropped. Every row points at a track that exists above. +The check that nothing was dropped. Every row points at a track. All seventeen items are +Θ or Ξ work, and both phases have since landed in full — their track sections are retired +from this file; see `docs/COMPLETED.md` for each track's full narrative. | # | Item (short) | Phase-Wave-Track | Worktree slug | |---|---|---|---| @@ -3246,10 +1744,11 @@ proof it exists to give. - **All of Phase Ψ** (`ppsi-*`). **Seven tracks across three waves**, from a direct list of seven defects and refinements (Daniel, 2026-08-01), not from `TODO-1.0.md`. Listed here as a block, like Γ; unlike Γ it has no backing product doc — the seven are - recorded verbatim in the phase header as its provenance (Ψ.1–Ψ.7), and the design - content lives inline in its tracks. The seventh track, Ψ-W3-T1, is not one of the - seven defects/refinements itself — it came from a review finding mid-phase; see the - Phase Ψ section for detail. + recorded verbatim in the phase header as its provenance (Ψ.1–Ψ.7). Phase Ψ has since + landed in full and its `PLAN.md` section is retired; the design content and the + Ψ.1–Ψ.7 list now live in `docs/COMPLETED.md`. The seventh track, Ψ-W3-T1, is not one of + the seven defects/refinements itself — it came from a review finding mid-phase; see + `docs/COMPLETED.md` for detail. - **Γ-W3-T2 `bake-reset-amendment` is a CORRECTION, not a feature**, and belongs on this list for a different reason from the others: it exists only because Ξ-W2-T1 shipped ahead of this plan's sequencing claim. If more corrections of this shape appear, they belong here rather @@ -3277,7 +1776,8 @@ proof it exists to give. day it was framed, so the track here is not gated on a decision; see "Decision state" above. **Ρ-F2 was the one that moved the spec** — the result track goes to Arrange unconditionally rather than following the active mode, which is also the only reason the - phase touches `panel_input.cpp` at all. + phase touches `panel_input.cpp` at all. **Ρ-W1-T1 has landed**; see `docs/COMPLETED.md` + for the full narrative. - **All of Phase Λ** (`pl-*`). **Thirteen pending tracks across seven waves** (Λ-W2…Λ-W8), plus Λ-W1's two audit tracks, which are landed. From a direct request (Daniel, 2026-08-02), not from `TODO-1.0.md`; the product reasoning lives in `docs/product/linux-readiness.md` @@ -3325,6 +1825,7 @@ than discovering it later: ``` Phase Θ — ReaSampler 9000: one parameter set, filter, shapeable envelopes, legible editor + [LANDED — all seven waves (W1-W7); see docs/COMPLETED.md; full detail section removed from this file] W1 Collapse and re-seam T1 zone-retirement ......................... 16 T2 capture-handoff-bugs .................... 5, 6 @@ -3347,6 +1848,7 @@ Phase Θ — ReaSampler 9000: one parameter set, filter, shapeable envelopes, le T1 arc-and-spline-aa ....................... (not one of the seventeen) Phase Ξ — The resample loop (W1 concurrency-safe with Θ from Θ-W2 onward) + [LANDED — all three waves (W1-W3); see docs/COMPLETED.md; full detail section removed from this file] W1 Consolidated tracking, and the programmed-note model T1 tracking-consolidation .................. 17 T2 note-program-model ...................... 15 (model) @@ -3393,6 +1895,7 @@ Phase Γ — The instrument's control surface (none of the seventeen; ends against what Xi-W2-T1 shipped, is resolved: both tracks landed — see docs/COMPLETED.md. Phase Psi — The extension trust pass (none of the seventeen; a direct list of seven) + [LANDED — all three waves (W1-W3); see docs/COMPLETED.md; full detail section removed from this file] W1 Exact bounds, disciplined switches, reachable actions, resolved drops [4 tracks] T1 capture-range-exactness ..... Psi.7 [opens with a DAW repro matrix] T2 mode-switch-discipline ...... Psi.2 + Psi.3 [one chokepoint: applyMode] @@ -3411,6 +1914,7 @@ Phase Psi — The extension trust pass (none of the seventeen; a direct exclusively. Phase Epsilon — The bank package (none of the seventeen; a direct request 2026-08-02) + [LANDED — all three waves (W1-W3); see docs/COMPLETED.md; full detail section removed from this file] W1 The contract, the filesystem, and the ledger's new kind [3 tracks, disjoint by dir] T1 package-format .............. core/package: framing + TWO version ints [E-F1 RULED: proprietary RSBK. No ZIP, no zlib] @@ -3439,6 +1943,7 @@ Phase Epsilon — The bank package (none of the seventeen; a direct reque textually adjacent only — serialize T2 behind T1 if zero contention is wanted. Phase Rho — Render in place (none of the seventeen; a direct request 2026-08-02) + [LANDED — Ρ-W1-T1; see docs/COMPLETED.md; full detail section removed from this file] W1 The verb [ONE track, deliberately] T1 render-in-place ............. render selected track -> new sibling track, item placed at the exact render position, diff --git a/docs/TODO.md b/docs/TODO.md index c1fa7b4..14b8c2d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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` 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::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. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index c8e8bc0..1d2ccdc 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -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.000–12.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.000–12.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 `/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 `/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 ` 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 ` 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 `` 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") diff --git a/docs/product/bank-package.md b/docs/product/bank-package.md index 92c9ee4..d011804 100644 --- a/docs/product/bank-package.md +++ b/docs/product/bank-package.md @@ -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 `/reasampler_bank/` (`core/capture/capture_paths.h:16`, +the audio sits in `/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 `` 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 `/.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. diff --git a/docs/product/capture-tail.md b/docs/product/capture-tail.md index 2d5391a..361b64e 100644 --- a/docs/product/capture-tail.md +++ b/docs/product/capture-tail.md @@ -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` ~L352–354). 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` ~L470–477; +`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, diff --git a/docs/product/code-organization.md b/docs/product/code-organization.md index c0b9357..65b7d3b 100644 --- a/docs/product/code-organization.md +++ b/docs/product/code-organization.md @@ -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:27–34`) 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:57–173`) 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) diff --git a/docs/product/linux-readiness.md b/docs/product/linux-readiness.md index c9e983f..b8afb29 100644 --- a/docs/product/linux-readiness.md +++ b/docs/product/linux-readiness.md @@ -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-t-` — Λ-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:33–36` +`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 (`:70–77`). `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:18–20`, 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:148–150`, `#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:28–30` 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:91–92`) — 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`, `:70–77`), -`src/shell/actions/instrument_drop_win.cpp` (`writeTempPreset`, `:50–61`), `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 `:135–137`, the dialog proc's platform contract, the drop-accept opt-in at -`:145–150`), `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:8–51` +**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:7–11` 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 `` 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, `:154–158`) | 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:18–22` 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 diff --git a/docs/product/midi-playback.md b/docs/product/midi-playback.md index 97e58c6..add589d 100644 --- a/docs/product/midi-playback.md +++ b/docs/product/midi-playback.md @@ -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 diff --git a/docs/product/render-in-place.md b/docs/product/render-in-place.md index f3afac9..da1873c 100644 --- a/docs/product/render-in-place.md +++ b/docs/product/render-in-place.md @@ -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 diff --git a/docs/product/versioning-and-release.md b/docs/product/versioning-and-release.md index a48b7c4..b3464cc 100644 --- a/docs/product/versioning-and-release.md +++ b/docs/product/versioning-and-release.md @@ -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. diff --git a/docs/verify-package-transfer.md b/docs/verify-package-transfer.md new file mode 100644 index 0000000..d701cc9 --- /dev/null +++ b/docs/verify-package-transfer.md @@ -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 `, 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: "".` +- 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 (` 2`), and the box's + `(a bank named "" 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 0–3 are `RSBK`; bytes 4–7 are `formatVersion` + little-endian; bytes **8–11** 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 and needs package format 2 or newer. +This build () reads package format 1. +Nothing was imported. Install ReaSampler 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 "" 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. diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 0b72280..2669575 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -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 diff --git a/src/app/main.cpp b/src/app/main.cpp index c6f8136..45d5c02 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -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 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; diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index de07360..0eeee11 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -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) diff --git a/src/core/capture/CLAUDE.md b/src/core/capture/CLAUDE.md index 104a57c..2f0e2fa 100644 --- a/src/core/capture/CLAUDE.md +++ b/src/core/capture/CLAUDE.md @@ -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. diff --git a/src/core/capture/capture_name.cpp b/src/core/capture/capture_name.cpp index c8aca5c..65209b1 100644 --- a/src/core/capture/capture_name.cpp +++ b/src/core/capture/capture_name.cpp @@ -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 diff --git a/src/core/capture/capture_name.h b/src/core/capture/capture_name.h index 803ac60..0225608 100644 --- a/src/core/capture/capture_name.h +++ b/src/core/capture/capture_name.h @@ -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 diff --git a/src/core/capture/capture_paths.cpp b/src/core/capture/capture_paths.cpp index 5d50501..e3ab731 100644 --- a/src/core/capture/capture_paths.cpp +++ b/src/core/capture/capture_paths.cpp @@ -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; } diff --git a/src/core/capture/capture_paths.h b/src/core/capture/capture_paths.h index 8ca0157..77049b1 100644 --- a/src/core/capture/capture_paths.h +++ b/src/core/capture/capture_paths.h @@ -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 "[_].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; // .wav + std::string fileStem; // (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 "[_].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); diff --git a/src/core/capture/render_settings.cpp b/src/core/capture/render_settings.cpp index 5cec2f2..1d72782 100644 --- a/src/core/capture/render_settings.cpp +++ b/src/core/capture/render_settings.cpp @@ -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; diff --git a/src/core/capture/render_settings.h b/src/core/capture/render_settings.h index 6115a63..3fdb844 100644 --- a/src/core/capture/render_settings.h +++ b/src/core/capture/render_settings.h @@ -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 ------------------------------------ diff --git a/src/core/capture/render_window.cpp b/src/core/capture/render_window.cpp index e32e642..82ec244 100644 --- a/src/core/capture/render_window.cpp +++ b/src/core/capture/render_window.cpp @@ -15,6 +15,13 @@ long long frameIndexAt(double seconds, int sampleRate) { return std::llround(seconds * static_cast(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 diff --git a/src/core/capture/render_window.h b/src/core/capture/render_window.h index a26bfdb..c736bee 100644 --- a/src/core/capture/render_window.h +++ b/src/core/capture/render_window.h @@ -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 diff --git a/src/core/capture/track_topology.cpp b/src/core/capture/track_topology.cpp index 58c9841..5db98ff 100644 --- a/src/core/capture/track_topology.cpp +++ b/src/core/capture/track_topology.cpp @@ -25,4 +25,38 @@ std::vector directChildIndices(const std::vector& folderDepths, return children; } +SiblingPlacement siblingPlacement(const std::vector& folderDepths, int srcIndex) { + const int count = static_cast(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 levels(static_cast(count) + 1, 0); + for (int i = 0; i < count; ++i) { + const int next = levels[static_cast(i)] + + folderDepths[static_cast(i)]; + levels[static_cast(i) + 1] = next < 0 ? 0 : next; + } + + const int L = levels[static_cast(src)]; + + int p = src + 1; + if (folderDepths[static_cast(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(j)] == L) { p = j; break; } + } + } + + SiblingPlacement out; + out.insertIndex = p; + out.precedingIndex = p - 1; + out.precedingDepth = L - levels[static_cast(p - 1)]; + out.newDepth = levels[static_cast(p)] - L; + return out; +} + } // namespace reasampler::capture diff --git a/src/core/capture/track_topology.h b/src/core/capture/track_topology.h index 2a55fa2..9903e47 100644 --- a/src/core/capture/track_topology.h +++ b/src/core/capture/track_topology.h @@ -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 @@ -21,4 +21,36 @@ namespace reasampler::capture { std::vector directChildIndices(const std::vector& 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& folderDepths, int srcIndex); + } // namespace reasampler::capture diff --git a/src/core/capture/wav_codec.cpp b/src/core/capture/wav_codec.cpp index c0b5d8a..8fd36e9 100644 --- a/src/core/capture/wav_codec.cpp +++ b/src/core/capture/wav_codec.cpp @@ -6,6 +6,7 @@ #include // std::snprintf (hash hex render) #include // std::memcpy, std::memcmp +#include // std::move namespace reasampler::capture { @@ -300,6 +301,23 @@ MonoCollapse collapseToMono(const std::vector& bytes) { return out; } +CollapsedWav applyMonoCollapse(std::vector 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 {}; diff --git a/src/core/capture/wav_codec.h b/src/core/capture/wav_codec.h index 88ab15d..4e92b47 100644 --- a/src/core/capture/wav_codec.h +++ b/src/core/capture/wav_codec.h @@ -117,6 +117,22 @@ struct MonoCollapse { // including the bext/source-position consequence beyond hashing. MonoCollapse collapseToMono(const std::vector& 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 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 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 diff --git a/src/core/model/bank_book.cpp b/src/core/model/bank_book.cpp index 17b4ff5..3ecb817 100644 --- a/src/core/model/bank_book.cpp +++ b/src/core/model/bank_book.cpp @@ -3,6 +3,8 @@ #include #include +#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); diff --git a/src/core/model/bank_book.h b/src/core/model/bank_book.h index 284654a..3a19d65 100644 --- a/src/core/model/bank_book.h +++ b/src/core/model/bank_book.h @@ -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. diff --git a/src/core/package/CLAUDE.md b/src/core/package/CLAUDE.md new file mode 100644 index 0000000..c5600b0 --- /dev/null +++ b/src/core/package/CLAUDE.md @@ -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_.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. diff --git a/src/core/package/CMakeLists.txt b/src/core/package/CMakeLists.txt new file mode 100644 index 0000000..8102725 --- /dev/null +++ b/src/core/package/CMakeLists.txt @@ -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) diff --git a/src/core/package/bank_package.cpp b/src/core/package/bank_package.cpp new file mode 100644 index 0000000..9b0edbe --- /dev/null +++ b/src/core/package/bank_package.cpp @@ -0,0 +1,145 @@ +#include "core/package/bank_package.h" + +#include + +#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& bytes) { + return bytes.size() >= kMagicBytes && + std::memcmp(bytes.data(), kPackageMagic, kMagicBytes) == 0; +} + +std::uint32_t u32At(const std::vector& bytes, std::size_t at) { + std::uint32_t v = 0; + for (std::size_t b = 0; b < 4; ++b) + v |= static_cast(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& entries, std::uint64_t firstOffset, + std::vector& 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 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(writer.size())); + out.insert(out.end(), writer.begin(), writer.end()); + wire::putLE(out, static_cast(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& 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 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 requiredPrefixSize(const std::vector& 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(throughSemver)); + if (manifestLen > kMaxManifestBytes) return std::nullopt; + return throughSemver + 4 + manifestLen; +} + +} // namespace reasampler::package diff --git a/src/core/package/bank_package.h b/src/core/package/bank_package.h new file mode 100644 index 0000000..84de7e9 --- /dev/null +++ b/src/core/package/bank_package.h @@ -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 +#include +#include +#include + +#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 prefix; + std::vector 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 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 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& 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 requiredPrefixSize(const std::vector& bytes); + +} // namespace reasampler::package diff --git a/src/core/package/export_plan.cpp b/src/core/package/export_plan.cpp new file mode 100644 index 0000000..4803cf0 --- /dev/null +++ b/src/core/package/export_plan.cpp @@ -0,0 +1,177 @@ +// export_plan.cpp — see export_plan.h for the contract. + +#include "core/package/export_plan.h" + +#include + +#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(s.back()) & 0xC0) == 0x80) s.pop_back(); + if (!s.empty() && static_cast(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& 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& 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(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(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 takenNames; + std::vector 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 diff --git a/src/core/package/export_plan.h b/src/core/package/export_plan.h new file mode 100644 index 0000000..0d7505a --- /dev/null +++ b/src/core/package/export_plan.h @@ -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 +#include + +#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 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 sourceRelativePaths; + + std::vector 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 diff --git a/src/core/package/import_plan.cpp b/src/core/package/import_plan.cpp new file mode 100644 index 0000000..1c9c38a --- /dev/null +++ b/src/core/package/import_plan.cpp @@ -0,0 +1,221 @@ +#include "core/package/import_plan.h" + +#include +#include +#include + +#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& 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 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& 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 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 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> 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 diff --git a/src/core/package/import_plan.h b/src/core/package/import_plan.h new file mode 100644 index 0000000..104df91 --- /dev/null +++ b/src/core/package/import_plan.h @@ -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 +#include +#include + +#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 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& bankFolderFileNames, + const std::string& uniqueTag); + +} // namespace reasampler::package diff --git a/src/core/package/package_format.cpp b/src/core/package/package_format.cpp new file mode 100644 index 0000000..34af822 --- /dev/null +++ b/src/core/package/package_format.cpp @@ -0,0 +1,126 @@ +#include "core/package/package_format.h" + +#include + +#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(c - 'A' + 'a') : static_cast(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(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(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(a[i])) != + lowerAscii(static_cast(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 diff --git a/src/core/package/package_format.h b/src/core/package/package_format.h new file mode 100644 index 0000000..346f5ff --- /dev/null +++ b/src/core/package/package_format.h @@ -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 +#include + +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 diff --git a/src/core/package/package_manifest.cpp b/src/core/package/package_manifest.cpp new file mode 100644 index 0000000..b634d05 --- /dev/null +++ b/src/core/package/package_manifest.cpp @@ -0,0 +1,230 @@ +#include "core/package/package_manifest.h" + +#include +#include + +#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& entries) { + std::unordered_set 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 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 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(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> 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(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 deserializeManifest(const std::string& json) { + PackageManifest m; + json::Reader r(json); + if (!parseManifest(r, m)) return std::nullopt; + return m; +} + +} // namespace reasampler::package diff --git a/src/core/package/package_manifest.h b/src/core/package/package_manifest.h new file mode 100644 index 0000000..906f0b6 --- /dev/null +++ b/src/core/package/package_manifest.h @@ -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 +#include +#include +#include + +#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 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 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 deserializeManifest(const std::string& json); + +} // namespace reasampler::package diff --git a/src/core/tracking/origin_ledger.cpp b/src/core/tracking/origin_ledger.cpp index dbcafd4..82568b7 100644 --- a/src/core/tracking/origin_ledger.cpp +++ b/src/core/tracking/origin_ledger.cpp @@ -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; } } diff --git a/src/core/tracking/origin_ledger.h b/src/core/tracking/origin_ledger.h index f04264e..da8d0e3 100644 --- a/src/core/tracking/origin_ledger.h +++ b/src/core/tracking/origin_ledger.h @@ -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 diff --git a/src/core/util/CLAUDE.md b/src/core/util/CLAUDE.md index 94ae77e..de8663f 100644 --- a/src/core/util/CLAUDE.md +++ b/src/core/util/CLAUDE.md @@ -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 diff --git a/src/core/util/ascii_ws.h b/src/core/util/ascii_ws.h new file mode 100644 index 0000000..a8e0c72 --- /dev/null +++ b/src/core/util/ascii_ws.h @@ -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 diff --git a/src/core/view/CLAUDE.md b/src/core/view/CLAUDE.md index b8ecfbd..11f4533 100644 --- a/src/core/view/CLAUDE.md +++ b/src/core/view/CLAUDE.md @@ -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. diff --git a/src/core/view/CMakeLists.txt b/src/core/view/CMakeLists.txt index ee1669e..8d8df40 100644 --- a/src/core/view/CMakeLists.txt +++ b/src/core/view/CMakeLists.txt @@ -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) diff --git a/src/core/view/fx_offline.cpp b/src/core/view/fx_offline.cpp new file mode 100644 index 0000000..98d5e9f --- /dev/null +++ b/src/core/view/fx_offline.cpp @@ -0,0 +1,92 @@ +#include "core/view/fx_offline.h" + +#include +#include + +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 slotByIdentity(const std::vector& liveFxGuids) { + std::map 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(i)); + } + return byGuid; +} + +} // namespace + +FxRestoreResolution resolveFxRestore(const std::vector& planned, + const std::vector& liveFxGuids) { + FxRestoreResolution res; + const std::map byGuid = slotByIdentity(liveFxGuids); + const int liveCount = static_cast(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 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 diff --git a/src/core/view/fx_offline.h b/src/core/view/fx_offline.h new file mode 100644 index 0000000..7e49e51 --- /dev/null +++ b/src/core/view/fx_offline.h @@ -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 +#include + +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 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& planned, + const std::vector& 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 diff --git a/src/core/view/view_mode_model.cpp b/src/core/view/view_mode_model.cpp index 81b53be..d7ce350 100644 --- a/src/core/view/view_mode_model.cpp +++ b/src/core/view/view_mode_model.cpp @@ -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(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(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 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& 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& snaps) { if (!r.consume('[')) return false; r.skipWs(); @@ -546,6 +621,10 @@ bool parseSnapshots(json::Reader& r, std::map& snaps std::string guid; TrackSnapshot snap; bool haveGuid = false; + std::vector bySlot; + bool haveSlot = false; + std::vector 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& 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(']'); } diff --git a/src/core/view/view_mode_model.h b/src/core/view/view_mode_model.h index 31d617d..b35d6ac 100644 --- a/src/core/view/view_mode_model.h +++ b/src/core/view/view_mode_model.h @@ -12,6 +12,7 @@ #include #include +#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 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 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 flags; std::vector fxOffline; diff --git a/src/shell/actions/CLAUDE.md b/src/shell/actions/CLAUDE.md index e5443df..cd53922 100644 --- a/src/shell/actions/CLAUDE.md +++ b/src/shell/actions/CLAUDE.md @@ -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 diff --git a/src/shell/actions/package_export_action.cpp b/src/shell/actions/package_export_action.cpp new file mode 100644 index 0000000..fcaebe3 --- /dev/null +++ b/src/shell/actions/package_export_action.cpp @@ -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 +#include +#include +#include +#include + +#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 buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(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& 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::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 diff --git a/src/shell/actions/package_export_action.h b/src/shell/actions/package_export_action.h new file mode 100644 index 0000000..695d5a3 --- /dev/null +++ b/src/shell/actions/package_export_action.h @@ -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 + +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 diff --git a/src/shell/actions/package_import_action.cpp b/src/shell/actions/package_import_action.cpp new file mode 100644 index 0000000..c8ca753 --- /dev/null +++ b/src/shell/actions/package_import_action.cpp @@ -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 + +#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 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 diff --git a/src/shell/actions/package_import_action.h b/src/shell/actions/package_import_action.h new file mode 100644 index 0000000..34f705e --- /dev/null +++ b/src/shell/actions/package_import_action.h @@ -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 + +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 diff --git a/src/shell/capture/CLAUDE.md b/src/shell/capture/CLAUDE.md index ea057b2..064b826 100644 --- a/src/shell/capture/CLAUDE.md +++ b/src/shell/capture/CLAUDE.md @@ -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 `/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 `/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. diff --git a/src/shell/capture/bake_landing.cpp b/src/shell/capture/bake_landing.cpp index a4ce618..2cf9e69 100644 --- a/src/shell/capture/bake_landing.cpp +++ b/src/shell/capture/bake_landing.cpp @@ -13,7 +13,7 @@ #include #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); diff --git a/src/shell/capture/bake_landing.h b/src/shell/capture/bake_landing.h index e7d9f1d..bcc2660 100644 --- a/src/shell/capture/bake_landing.h +++ b/src/shell/capture/bake_landing.h @@ -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 #include diff --git a/src/shell/capture/capture.cpp b/src/shell/capture/capture.cpp index 6e7355a..1bd3993 100644 --- a/src/shell/capture/capture.cpp +++ b/src/shell/capture/capture.cpp @@ -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 recDir(4096, '\0'); + GetProjectPathEx(proj, recDir.data(), static_cast(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(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(effectiveSampleRate), true); } + GetSetProjectInfo(proj, "RENDER_CHANNELS", static_cast(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; } diff --git a/src/shell/capture/capture.h b/src/shell/capture/capture.h index c52b460..dc640f1 100644 --- a/src/shell/capture/capture.h +++ b/src/shell/capture/capture.h @@ -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, // /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 diff --git a/src/shell/capture/capture_orchestrator.cpp b/src/shell/capture/capture_orchestrator.cpp index 09ca4c7..a59ecdd 100644 --- a/src/shell/capture/capture_orchestrator.cpp +++ b/src/shell/capture/capture_orchestrator.cpp @@ -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 diff --git a/src/shell/capture/capture_realtime_shell.cpp b/src/shell/capture/capture_realtime_shell.cpp index 41a307b..e596091 100644 --- a/src/shell/capture/capture_realtime_shell.cpp +++ b/src/shell/capture/capture_realtime_shell.cpp @@ -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); diff --git a/src/shell/capture/render_bounds_gate.cpp b/src/shell/capture/render_bounds_gate.cpp index 9fffa26..97b7bce 100644 --- a/src/shell/capture/render_bounds_gate.cpp +++ b/src/shell/capture/render_bounds_gate.cpp @@ -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(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; } diff --git a/src/shell/capture/render_bounds_gate.h b/src/shell/capture/render_bounds_gate.h index 27a4eb1..55a3433 100644 --- a/src/shell/capture/render_bounds_gate.h +++ b/src/shell/capture/render_bounds_gate.h @@ -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 diff --git a/src/shell/capture/render_in_place.cpp b/src/shell/capture/render_in_place.cpp new file mode 100644 index 0000000..cbe0da0 --- /dev/null +++ b/src/shell/capture/render_in_place.cpp @@ -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 +#include + +#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 folderDepths(ReaProject* proj, int count) { + std::vector depths; + depths.reserve(static_cast(count < 0 ? 0 : count)); + for (int i = 0; i < count; ++i) { + MediaTrack* tr = GetTrack(proj, i); + depths.push_back(tr ? static_cast( + 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 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(place.precedingDepth)); + } + SetMediaTrackInfo_Value(fresh, "I_FOLDERDEPTH", + static_cast(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(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 diff --git a/src/shell/capture/render_in_place.h b/src/shell/capture/render_in_place.h new file mode 100644 index 0000000..249e0f5 --- /dev/null +++ b/src/shell/capture/render_in_place.h @@ -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 diff --git a/src/shell/package/CLAUDE.md b/src/shell/package/CLAUDE.md new file mode 100644 index 0000000..dede777 --- /dev/null +++ b/src/shell/package/CLAUDE.md @@ -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. diff --git a/src/shell/package/CMakeLists.txt b/src/shell/package/CMakeLists.txt new file mode 100644 index 0000000..497f8e5 --- /dev/null +++ b/src/shell/package/CMakeLists.txt @@ -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() diff --git a/src/shell/package/export_bank.cpp b/src/shell/package/export_bank.cpp new file mode 100644 index 0000000..d2aee61 --- /dev/null +++ b/src/shell/package/export_bank.cpp @@ -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 +#include +#include + +#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 absoluteSources(const std::string& projectDir, + const std::vector& relativePaths) { + std::vector 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& 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& 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 sources = + absoluteSources(req.projectDir, survey.plan.sourceRelativePaths); + + if (!digestSources(manifest, sources, out.offendingName)) { + out.status = ExportStatus::SourceReadFailed; + return out; + } + const std::optional 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 diff --git a/src/shell/package/export_bank.h b/src/shell/package/export_bank.h new file mode 100644 index 0000000..d78ee3f --- /dev/null +++ b/src/shell/package/export_bank.h @@ -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 +#include +#include +#include + +#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 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& 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& 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 diff --git a/src/shell/package/import_bank.cpp b/src/shell/package/import_bank.cpp new file mode 100644 index 0000000..2261361 --- /dev/null +++ b/src/shell/package/import_bank.cpp @@ -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 +#include +#include +#include + +#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 buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(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::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 diff --git a/src/shell/package/import_bank.h b/src/shell/package/import_bank.h new file mode 100644 index 0000000..018ac9d --- /dev/null +++ b/src/shell/package/import_bank.h @@ -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 + +#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 diff --git a/src/shell/package/import_landing.cpp b/src/shell/package/import_landing.cpp new file mode 100644 index 0000000..d4c1a26 --- /dev/null +++ b/src/shell/package/import_landing.cpp @@ -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 +#include +#include +#include +#include +#include + +#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& 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 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 diff --git a/src/shell/package/import_landing.h b/src/shell/package/import_landing.h new file mode 100644 index 0000000..0731892 --- /dev/null +++ b/src/shell/package/import_landing.h @@ -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 +#include + +#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; + +// 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 diff --git a/src/shell/package/package_io.cpp b/src/shell/package/package_io.cpp new file mode 100644 index 0000000..f057f03 --- /dev/null +++ b/src/shell/package/package_io.cpp @@ -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 +#include +#include +#include + +#include "shell/package/package_path.h" + +#ifdef _WIN32 +#include +#include +#include +#include +#else +#include +#include +#include +#endif + +namespace reasampler { + +namespace fs = std::filesystem; + +namespace { +std::atomic g_alivePayloads{0}; +std::atomic g_peakAlivePayloads{0}; +} + +// --------------------------------------------------------------------------- +// PayloadBuffer + +PayloadBuffer::PayloadBuffer(std::vector 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(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(data), + static_cast(len)); + ok_ = static_cast(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(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(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::numeric_limits::max())) { + return PayloadBuffer{}; + } + in_.clear(); // a prior failed read must not poison this one + in_.seekg(static_cast(offset)); + if (!in_) return PayloadBuffer{}; + std::vector bytes(static_cast(length)); + in_.read(reinterpret_cast(bytes.data()), + static_cast(length)); + if (static_cast(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(payload.size() - written, 1u << 20); +#ifdef _WIN32 + const int n = _write(fd, payload.data() + written, + static_cast(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(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 listFolderFileNames(const std::string& dirAbsPath) { + std::vector 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 diff --git a/src/shell/package/package_io.h b/src/shell/package/package_io.h new file mode 100644 index 0000000..384a06b --- /dev/null +++ b/src/shell/package/package_io.h @@ -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 +#include +#include +#include +#include + +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 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 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 listFolderFileNames(const std::string& dirAbsPath); + +} // namespace reasampler diff --git a/src/shell/package/package_path.h b/src/shell/package/package_path.h new file mode 100644 index 0000000..e70db66 --- /dev/null +++ b/src/shell/package/package_path.h @@ -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 +#include + +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 diff --git a/src/shell/package/package_pickers.cpp b/src/shell/package/package_pickers.cpp new file mode 100644 index 0000000..da04894 --- /dev/null +++ b/src/shell/package/package_pickers.cpp @@ -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 +#include + +#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(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 diff --git a/src/shell/package/package_pickers.h b/src/shell/package/package_pickers.h new file mode 100644 index 0000000..136a6d9 --- /dev/null +++ b/src/shell/package/package_pickers.h @@ -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 + +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 diff --git a/src/shell/package/package_rollback.cpp b/src/shell/package/package_rollback.cpp new file mode 100644 index 0000000..c3162d0 --- /dev/null +++ b/src/shell/package/package_rollback.cpp @@ -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 + +#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 diff --git a/src/shell/package/package_rollback.h b/src/shell/package/package_rollback.h new file mode 100644 index 0000000..438f168 --- /dev/null +++ b/src/shell/package/package_rollback.h @@ -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 +#include + +#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& landedPaths() const { return paths_; } + bool empty() const { return paths_.empty(); } + +private: + std::vector paths_; + bool indexCommitted_ = false; +}; + +} // namespace reasampler diff --git a/src/shell/panel/CLAUDE.md b/src/shell/panel/CLAUDE.md index b8da4c2..0cc9763 100644 --- a/src/shell/panel/CLAUDE.md +++ b/src/shell/panel/CLAUDE.md @@ -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. diff --git a/src/shell/panel/panel_bank_ops.cpp b/src/shell/panel/panel_bank_ops.cpp index 0ce2fbb..d3700ef 100644 --- a/src/shell/panel/panel_bank_ops.cpp +++ b/src/shell/panel/panel_bank_ops.cpp @@ -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; } } diff --git a/src/shell/panel/panel_input.cpp b/src/shell/panel/panel_input.cpp index 8fbd912..7eb5b79 100644 --- a/src/shell/panel/panel_input.cpp +++ b/src/shell/panel/panel_input.cpp @@ -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 #include #include #include @@ -163,11 +164,19 @@ bool detectNewContent() { std::map> trackItemGuids; enumerateLiveGuids(proj, live, itemOnManualLane, trackItemGuids); - const std::vector added = g_panel.contentBaseline.observe(live); + std::vector 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. diff --git a/src/shell/panel/panel_window.cpp b/src/shell/panel/panel_window.cpp index 9cc90f8..12a188d 100644 --- a/src/shell/panel/panel_window.cpp +++ b/src/shell/panel/panel_window.cpp @@ -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 // 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(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 paths; + std::vector 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 buf(static_cast(len) + 1, '\0'); DragQueryFile(hDrop, i, buf.data(), static_cast(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); } diff --git a/src/shell/persist/session.h b/src/shell/persist/session.h index ffbaad0..0bc1967 100644 --- a/src/shell/persist/session.h +++ b/src/shell/persist/session.h @@ -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_; } diff --git a/src/shell/view/CLAUDE.md b/src/shell/view/CLAUDE.md index ac49705..b8ab91a 100644 --- a/src/shell/view/CLAUDE.md +++ b/src/shell/view/CLAUDE.md @@ -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. diff --git a/src/shell/view/view.cpp b/src/shell/view/view.cpp index a81058f..f12520c 100644 --- a/src/shell/view/view.cpp +++ b/src/shell/view/view.cpp @@ -14,6 +14,7 @@ #include #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>& 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 liveFxGuids(MediaTrack* tr) { + const int fxCount = TrackFX_GetCount(tr); + std::vector guids; + guids.reserve(static_cast(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(GetMediaTrackInfo_Value(tr, "B_MAINSEND")); snap.fxEnable = static_cast(GetMediaTrackInfo_Value(tr, "I_FXEN")); - int fxCount = TrackFX_GetCount(tr); - snap.fxOffline.reserve(static_cast(fxCount)); - for (int fx = 0; fx < fxCount; ++fx) { - snap.fxOffline.push_back(TrackFX_GetOffline(tr, fx) ? 1 : 0); + const std::vector 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(fx)) ? 1 : 0}); } - return snap; + return snap; // fxKeying stays Identity — a live capture always knows the chain } void applyFlags(MediaTrack* tr, const std::vector& 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& 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& 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. diff --git a/tests/fixtures/package_compat/README.md b/tests/fixtures/package_compat/README.md new file mode 100644 index 0000000..837d7ec --- /dev/null +++ b/tests/fixtures/package_compat/README.md @@ -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. diff --git a/tests/fixtures/package_compat/additive_forward.rsbank b/tests/fixtures/package_compat/additive_forward.rsbank new file mode 100644 index 0000000..656d9dd Binary files /dev/null and b/tests/fixtures/package_compat/additive_forward.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_dotdot.rsbank b/tests/fixtures/package_compat/hostile_name_dotdot.rsbank new file mode 100644 index 0000000..30af026 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_dotdot.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_drive_absolute.rsbank b/tests/fixtures/package_compat/hostile_name_drive_absolute.rsbank new file mode 100644 index 0000000..6ef9963 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_drive_absolute.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_parent_backslash.rsbank b/tests/fixtures/package_compat/hostile_name_parent_backslash.rsbank new file mode 100644 index 0000000..bf100cb Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_parent_backslash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_parent_slash.rsbank b/tests/fixtures/package_compat/hostile_name_parent_slash.rsbank new file mode 100644 index 0000000..c8145cb Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_parent_slash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_subdir_slash.rsbank b/tests/fixtures/package_compat/hostile_name_subdir_slash.rsbank new file mode 100644 index 0000000..d473a8e Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_subdir_slash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_name_unc_absolute.rsbank b/tests/fixtures/package_compat/hostile_name_unc_absolute.rsbank new file mode 100644 index 0000000..d50717c Binary files /dev/null and b/tests/fixtures/package_compat/hostile_name_unc_absolute.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_dotdot_backslash.rsbank b/tests/fixtures/package_compat/hostile_path_dotdot_backslash.rsbank new file mode 100644 index 0000000..700a42c Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_dotdot_backslash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_dotdot_slash.rsbank b/tests/fixtures/package_compat/hostile_path_dotdot_slash.rsbank new file mode 100644 index 0000000..b203f8e Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_dotdot_slash.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_drive_absolute.rsbank b/tests/fixtures/package_compat/hostile_path_drive_absolute.rsbank new file mode 100644 index 0000000..08fead8 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_drive_absolute.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_rooted.rsbank b/tests/fixtures/package_compat/hostile_path_rooted.rsbank new file mode 100644 index 0000000..359c3d1 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_rooted.rsbank differ diff --git a/tests/fixtures/package_compat/hostile_path_unc_absolute.rsbank b/tests/fixtures/package_compat/hostile_path_unc_absolute.rsbank new file mode 100644 index 0000000..8e482b1 Binary files /dev/null and b/tests/fixtures/package_compat/hostile_path_unc_absolute.rsbank differ diff --git a/tests/fixtures/package_compat/refuse_structural.rsbank b/tests/fixtures/package_compat/refuse_structural.rsbank new file mode 100644 index 0000000..5e751b3 Binary files /dev/null and b/tests/fixtures/package_compat/refuse_structural.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_additive_forward.rsbank b/tests/fixtures/package_compat/trunc_additive_forward.rsbank new file mode 100644 index 0000000..7e5e50d Binary files /dev/null and b/tests/fixtures/package_compat/trunc_additive_forward.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_magic.rsbank b/tests/fixtures/package_compat/trunc_magic.rsbank new file mode 100644 index 0000000..0ea8a97 --- /dev/null +++ b/tests/fixtures/package_compat/trunc_magic.rsbank @@ -0,0 +1 @@ +RS \ No newline at end of file diff --git a/tests/fixtures/package_compat/trunc_manifest_body.rsbank b/tests/fixtures/package_compat/trunc_manifest_body.rsbank new file mode 100644 index 0000000..05d37e4 Binary files /dev/null and b/tests/fixtures/package_compat/trunc_manifest_body.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_manifest_length.rsbank b/tests/fixtures/package_compat/trunc_manifest_length.rsbank new file mode 100644 index 0000000..3ab399a Binary files /dev/null and b/tests/fixtures/package_compat/trunc_manifest_length.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_one_short.rsbank b/tests/fixtures/package_compat/trunc_one_short.rsbank new file mode 100644 index 0000000..79b4f3b Binary files /dev/null and b/tests/fixtures/package_compat/trunc_one_short.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_payload_middle.rsbank b/tests/fixtures/package_compat/trunc_payload_middle.rsbank new file mode 100644 index 0000000..ac9ddd2 Binary files /dev/null and b/tests/fixtures/package_compat/trunc_payload_middle.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_payload_start.rsbank b/tests/fixtures/package_compat/trunc_payload_start.rsbank new file mode 100644 index 0000000..c7a3a72 Binary files /dev/null and b/tests/fixtures/package_compat/trunc_payload_start.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_version_pair.rsbank b/tests/fixtures/package_compat/trunc_version_pair.rsbank new file mode 100644 index 0000000..c66f6db Binary files /dev/null and b/tests/fixtures/package_compat/trunc_version_pair.rsbank differ diff --git a/tests/fixtures/package_compat/trunc_writer_semver.rsbank b/tests/fixtures/package_compat/trunc_writer_semver.rsbank new file mode 100644 index 0000000..430300e Binary files /dev/null and b/tests/fixtures/package_compat/trunc_writer_semver.rsbank differ diff --git a/tests/fixtures/package_compat/v1_shipping.rsbank b/tests/fixtures/package_compat/v1_shipping.rsbank new file mode 100644 index 0000000..8c85831 Binary files /dev/null and b/tests/fixtures/package_compat/v1_shipping.rsbank differ diff --git a/tests/package_fixtures.h b/tests/package_fixtures.h new file mode 100644 index 0000000..b7cab67 --- /dev/null +++ b/tests/package_fixtures.h @@ -0,0 +1,25 @@ +#pragma once +// The one accessor over the frozen package-compat corpus (tests/fixtures/package_compat). +// REASAMPLER_PACKAGE_FIXTURE_DIR is a compile-time absolute path defined by each +// consuming test target: the corpus is source-tree data, and a test's working directory +// under ctest differs between single- and multi-config generators, so no relative +// spelling reaches it from both. +// +// Every caller must check the returned size: a fixture that failed to open reads as an +// empty buffer, which a "this must be Malformed" assertion would otherwise pass. + +#include +#include +#include +#include +#include + +inline std::string packageFixturePath(const std::string& name) { + return std::string(REASAMPLER_PACKAGE_FIXTURE_DIR) + "/" + name; +} + +inline std::vector packageFixtureBytes(const std::string& name) { + std::ifstream f(packageFixturePath(name), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} diff --git a/tests/test_bank_package.cpp b/tests/test_bank_package.cpp new file mode 100644 index 0000000..8783230 --- /dev/null +++ b/tests/test_bank_package.cpp @@ -0,0 +1,415 @@ +// Standalone tests for reasampler::package::bank_package — no REAPER, no test +// framework. Byte-level suites hand-roll RSBK images with wire::putLE rather +// than calling encodePackage, so a layout regression in encode cannot hide from +// decode (the two sides are pinned against each other AND against raw bytes). + +#include "../src/core/package/bank_package.h" + +#include +#include +#include +#include + +#include "../src/core/version/app_version.h" +#include "../src/core/wire/bytes.h" + +using namespace reasampler::package; +using namespace reasampler::model; +namespace wire = reasampler::wire; +namespace version = reasampler::version; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- fixtures ---------------------------------------------------------------- + +// Every Sample field populated, every optional PRESENT. +static Sample fullSample() { + Sample s; + s.id = "smp-full"; + s.displayName = "Kick (wet)"; + s.relativePath = "reasampler_bank/kick.wav"; + s.sourceMode = SourceMode::RazorArea; + s.sourceRange = {1.25, 3.5, 480.0, 1920.0}; + s.trackGuids = {"{AAA}", "{BBB}"}; + s.wetDry = 0.75; + s.channelCount = 2; + s.sampleRate = 48000; + s.lengthSeconds = 2.25; + s.lengthBeats = 4.5; + s.captureTempo = 120.5; + s.captureTimeSigNum = 7; + s.captureTimeSigDenom = 8; + s.key = "F#m"; + s.rootNote = 60; + s.loop = LoopPoints{100, 4800}; + s.levels = {-0.3, -12.7, -14.0}; + s.clipped = true; + s.tier = Tier::Archive; + s.contentHash = "W0123456789abcdef"; + s.provenance = Provenance{"smp-parent", "fx-snapshot"}; + s.createdTimestamp = 1754000000; + return s; +} + +// Every Sample optional ABSENT (key, rootNote, loop, provenance). +static Sample bareSample() { + Sample s; + s.id = "smp-bare"; + s.displayName = "Snare"; + s.relativePath = "reasampler_bank/snare.wav"; + s.sourceMode = SourceMode::Realtime; + s.contentHash = "Wfedcba9876543210"; + s.createdTimestamp = 1754000001; + return s; +} + +static PackageManifest fixture(std::uint64_t len0, std::uint64_t len1) { + PackageManifest m; + m.bankDisplayName = "Drums \"live\""; + m.exportTimestamp = 1754100000; + m.entries.push_back({"kick.wav", len0, "1111222233334444", fullSample()}); + m.entries.push_back({"snare.wav", len1, "5555666677778888", bareSample()}); + m.slots.append("smp-full"); + m.slots.append("smp-bare"); + return m; +} + +// A hand-rolled RSBK image: frozen region + a raw tail (manifest framing or +// deliberate garbage), independent of encodePackage. +static std::vector rawHeader(std::uint32_t fv, std::uint32_t mv, + const std::string& semver) { + std::vector out; + out.insert(out.end(), kPackageMagic, kPackageMagic + 4); + wire::putLE(out, fv); + wire::putLE(out, mv); + wire::putLE(out, static_cast(semver.size())); + out.insert(out.end(), semver.begin(), semver.end()); + return out; +} + +static void appendManifest(std::vector& out, const std::string& json) { + wire::putLE(out, static_cast(json.size())); + out.insert(out.end(), json.begin(), json.end()); +} + +// One-entry manifest JSON with `extra` spliced in as additional root content +// ("" for none) and `sampleExtra` spliced into the nested Sample object — for +// images a current writer would never emit. +static std::string handManifest(const std::string& name, int length, + const std::string& extra, + const std::string& sampleExtra = "") { + return std::string("{") + extra + + "\"entries\":[{\"name\":\"" + name + + "\",\"length\":" + std::to_string(length) + ",\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\"," + sampleExtra + + "\"relativePath\":\"bank/a.wav\"}]}}]}"; +} + +// --- encode / decode round trip ---------------------------------------------- + +static void testEncodeDecodeRoundTrip() { + const PackageManifest m = fixture(96000, 48000); + auto enc = encodePackage(m); + CHECK(enc.has_value()); + + // Layout arithmetic: payloads start at the prefix end, in manifest order. + CHECK(enc->layout.size() == 2); + CHECK(enc->layout[0].name == "kick.wav"); + CHECK(enc->layout[0].offset == enc->prefix.size()); + CHECK(enc->layout[0].length == 96000); + CHECK(enc->layout[1].offset == enc->prefix.size() + 96000); + CHECK(enc->layout[1].length == 48000); + CHECK(enc->totalSize == enc->prefix.size() + 96000 + 48000); + + // decodePackage(encodePackage(x)) == x — payloads are never read by the + // codec, so the prefix plus the true total size is the whole input. + const DecodedPackage dec = decodePackage(enc->prefix, enc->totalSize); + CHECK(dec.status == PackageReadability::Readable); + CHECK(dec.manifest == m); + CHECK(dec.layout == enc->layout); + CHECK(dec.prefixSize == enc->prefix.size()); + + // The header stamps this build's ladder pair and its informational semver. + CHECK(dec.header.formatVersion == kPackageFormatVersion); + CHECK(dec.header.minReaderVersion == kPackageMinReaderVersion); + CHECK(dec.header.writerVersion == version::stampVersion()); +} + +static void testEncodeRefusesWhatManifestRefuses() { + PackageManifest m = fixture(1, 1); + m.entries[0].fileName = "../evil.wav"; + CHECK(!encodePackage(m).has_value()); + + // See src/core/package/CLAUDE.md for the shell seam that forces this. + PackageManifest zeroLen = fixture(1, 1); + zeroLen.entries[1].byteLength = 0; + CHECK(!encodePackage(zeroLen).has_value()); +} + +// --- truncation: every byte offset ------------------------------------------- + +static void testTruncationAtEveryByteOffsetIsMalformed() { + const PackageManifest m = fixture(3, 5); + auto enc = encodePackage(m); + CHECK(enc.has_value()); + + // The complete on-disk image: prefix + both payloads. + std::vector file = enc->prefix; + for (std::uint8_t b : {1, 2, 3, 10, 20, 30, 40, 50}) file.push_back(b); + CHECK(file.size() == enc->totalSize); + CHECK(decodePackage(file, file.size()).status == PackageReadability::Readable); + + for (std::size_t cut = 0; cut < file.size(); ++cut) { + const std::vector truncated(file.begin(), file.begin() + cut); + const DecodedPackage dec = decodePackage(truncated, truncated.size()); + if (dec.status != PackageReadability::Malformed) { + std::printf("FAIL: truncation at %zu not Malformed\n", cut); + ++g_fail; + break; + } + // A refused decode must not half-succeed at any cut either. + CHECK(dec.manifest.entries.empty()); + CHECK(dec.layout.empty()); + } + + // One byte extra (trailing garbage) is as Malformed as one byte missing. + std::vector extended = file; + extended.push_back(0); + CHECK(decodePackage(extended, extended.size()).status == PackageReadability::Malformed); + // And a file size that disagrees with the same bytes. + CHECK(decodePackage(file, file.size() + 1).status == PackageReadability::Malformed); + CHECK(decodePackage(file, file.size() - 1).status == PackageReadability::Malformed); +} + +// --- header defaults --------------------------------------------------------- + +// The 0/0 defaults are not the current ladder pair, so a header that never +// parsed cannot be mistaken for a plausible 1/1. They mean "unset", NOT "the +// decode failed": a decode that got past the header reports the real pair +// alongside its Malformed verdict. +static void testHeaderDefaultsMeanUnparsed() { + CHECK(PackageHeader{}.formatVersion == 0); + CHECK(PackageHeader{}.minReaderVersion == 0); + CHECK(PackageHeader{}.writerVersion.empty()); + + // Failed before the header: bad magic, and a semver truncated mid-string. + std::vector badMagic = rawHeader(1, 1, "1.0.0"); + appendManifest(badMagic, "{}"); + badMagic[0] = 'Z'; + const DecodedPackage magic = decodePackage(badMagic, badMagic.size()); + CHECK(magic.status == PackageReadability::Malformed); + CHECK(magic.header == PackageHeader{}); + + std::vector cutSemver = rawHeader(1, 1, "1.0.0"); + cutSemver.resize(18); + CHECK(decodePackage(cutSemver, cutSemver.size()).header == PackageHeader{}); + + // Failed after it: a same-version package with a corrupt manifest is + // Malformed, and its header is fully populated. + std::vector corrupt = + rawHeader(kPackageFormatVersion, kPackageMinReaderVersion, "1.0.0"); + appendManifest(corrupt, "not json"); + const DecodedPackage late = decodePackage(corrupt, corrupt.size()); + CHECK(late.status == PackageReadability::Malformed); + CHECK(late.header.formatVersion == kPackageFormatVersion); + CHECK(late.header.minReaderVersion == kPackageMinReaderVersion); + CHECK(late.header.writerVersion == "1.0.0"); +} + +// --- version ladder: TooNew refuses whole ------------------------------------ + +static void testTooNewProducesNoManifest() { + // A future structural format: only the frozen region is trustworthy, so the + // tail is deliberate garbage that would crash a parser that kept reading. + std::vector bytes = rawHeader(9, 9, "9.9.9"); + for (int i = 0; i < 32; ++i) bytes.push_back(0xFF); + + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::TooNew); + // The refusal message's three facts survive... + CHECK(dec.header.formatVersion == 9); + CHECK(dec.header.minReaderVersion == 9); + CHECK(dec.header.writerVersion == "9.9.9"); + // ...and nothing else is produced: no manifest, no layout, no half-success. + CHECK(dec.manifest.entries.empty()); + CHECK(dec.manifest == PackageManifest{}); + CHECK(dec.layout.empty()); + CHECK(dec.prefixSize == 0); + + // Boundary: minReader exactly one past this build. + auto boundary = rawHeader(kPackageFormatVersion + 1, kPackageFormatVersion + 1, "2.0.0"); + CHECK(decodePackage(boundary, boundary.size()).status == PackageReadability::TooNew); + + // A TooNew header truncated inside the frozen region cannot name the + // writer, so it is Malformed, not an unactionable refusal. + std::vector cut = rawHeader(9, 9, "9.9.9"); + cut.resize(18); // mid-semver + CHECK(decodePackage(cut, cut.size()).status == PackageReadability::Malformed); +} + +// An additively-tagged package (fv > ours, minReader still within reach) whose +// manifest fails to parse: the header classifies Readable, so decode reads +// into the manifest and fails there. That failure must still report TooNew — +// the header is valid and already carries the writer's semver — not the +// unactionable Malformed a genuinely corrupt header produces. +static void testAdditiveUnparseableManifestIsTooNew() { + std::vector bytes = rawHeader(kPackageFormatVersion + 1, kPackageMinReaderVersion, "1.9.0"); + appendManifest(bytes, "not json"); + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::TooNew); + CHECK(dec.header.formatVersion == kPackageFormatVersion + 1); + CHECK(dec.header.minReaderVersion == kPackageMinReaderVersion); + CHECK(dec.header.writerVersion == "1.9.0"); + CHECK(dec.manifest.entries.empty()); + CHECK(dec.layout.empty()); + + // Same-version unparseable manifest stays Malformed: nothing "newer" + // excuses it, so this is not a blanket "unparseable == TooNew" rule. + std::vector sameVersion = + rawHeader(kPackageFormatVersion, kPackageMinReaderVersion, "1.0.0"); + appendManifest(sameVersion, "not json"); + CHECK(decodePackage(sameVersion, sameVersion.size()).status == PackageReadability::Malformed); +} + +// --- version ladder: additive forward compatibility -------------------------- + +// The reason two integers exist: a NEWER formatVersion whose minReaderVersion +// still reaches back to this build must read, with its unknown keys skipped — +// at the manifest level AND inside the nested Sample blob, the actual +// motivating case for the two-integer ladder (see this directory's CLAUDE.md). +static void testNewerAdditiveFormatReads() { + const std::string manifest = handManifest( + "a.wav", 4, + "\"instrumentState\":{\"future\":[1,2,3]},\"anotherNewKey\":\"x\",", + "\"someFutureSampleField\":42,"); + std::vector bytes = + rawHeader(kPackageFormatVersion + 1, kPackageMinReaderVersion, "1.9.0"); + appendManifest(bytes, manifest); + const std::uint64_t total = bytes.size() + 4; // the one entry's payload + + const DecodedPackage dec = decodePackage(bytes, total); + CHECK(dec.status == PackageReadability::Readable); + CHECK(dec.header.formatVersion == kPackageFormatVersion + 1); + CHECK(dec.header.writerVersion == "1.9.0"); + CHECK(dec.manifest.entries.size() == 1); + CHECK(dec.manifest.entries[0].fileName == "a.wav"); + CHECK(dec.manifest.entries[0].sample.id == "s1"); + CHECK(dec.layout.size() == 1); + CHECK(dec.layout[0].offset == bytes.size()); + CHECK(dec.layout[0].length == 4); +} + +// --- hostile headers --------------------------------------------------------- + +static void testHostileHeadersAreMalformed() { + // Wrong magic. + std::vector bad = rawHeader(1, 1, "1.0.0"); + appendManifest(bad, "{}"); + bad[0] = 'Z'; + CHECK(decodePackage(bad, bad.size()).status == PackageReadability::Malformed); + + // Incoherent version pairs (the classify rules, proven through the framing). + for (auto [fv, mv] : {std::pair{0, 0}, {0, 1}, + {1, 0}, {1, 2}}) { + std::vector b = rawHeader(fv, mv, "1.0.0"); + appendManifest(b, "{}"); + CHECK(decodePackage(b, b.size()).status == PackageReadability::Malformed); + } + + // A forged semver length over the cap must not read past the buffer. + std::vector overSemver; + overSemver.insert(overSemver.end(), kPackageMagic, kPackageMagic + 4); + wire::putLE(overSemver, std::uint32_t{1}); + wire::putLE(overSemver, std::uint32_t{1}); + wire::putLE(overSemver, kMaxWriterVersionBytes + 1); + CHECK(decodePackage(overSemver, overSemver.size()).status == + PackageReadability::Malformed); + + // A forged manifest length over the cap: refused before any allocation. + std::vector overManifest = rawHeader(1, 1, "1.0.0"); + wire::putLE(overManifest, kMaxManifestBytes + 1); + CHECK(decodePackage(overManifest, overManifest.size()).status == + PackageReadability::Malformed); + + // A manifest whose entry name expresses a path: rejected on decode even + // though no current encoder would write it. + std::vector traversal = rawHeader(1, 1, "1.0.0"); + appendManifest(traversal, handManifest("../evil.wav", 4, "")); + CHECK(decodePackage(traversal, traversal.size() + 4).status == + PackageReadability::Malformed); +} + +// --- requiredPrefixSize ------------------------------------------------------ + +static void testRequiredPrefixSizeGrowsToTheFullPrefix() { + auto enc = encodePackage(fixture(3, 5)); + CHECK(enc.has_value()); + const auto& prefix = enc->prefix; + + // Empty: the fixed region first. + CHECK(requiredPrefixSize({}) == std::uint64_t{16}); + + // With the fixed region: asks through the semver + manifest-length field. + const std::string& semver = version::stampVersion(); + std::vector first16(prefix.begin(), prefix.begin() + 16); + CHECK(requiredPrefixSize(first16) == std::uint64_t{16 + semver.size() + 4}); + + // With that much: the full prefix size. And the answer is a fixpoint. + std::vector upToManifestLen( + prefix.begin(), prefix.begin() + 20 + static_cast(semver.size())); + CHECK(requiredPrefixSize(upToManifestLen) == std::uint64_t{prefix.size()}); + CHECK(requiredPrefixSize(prefix) == std::uint64_t{prefix.size()}); + + // The returned count is exactly enough for decodePackage. + CHECK(decodePackage(prefix, enc->totalSize).status == PackageReadability::Readable); +} + +static void testRequiredPrefixSizeRefusals() { + // Bad magic: stop reading. + std::vector bad(16, 0); + CHECK(!requiredPrefixSize(bad).has_value()); + + // Incoherent versions: stop reading. + std::vector zeroed = rawHeader(0, 0, "1.0.0"); + CHECK(!requiredPrefixSize(zeroed).has_value()); + + // TooNew: asks only through the frozen region — the manifest-length field + // belongs to the newer format and is never trusted. + std::vector tooNew = rawHeader(9, 9, "9.9.9"); + for (int i = 0; i < 8; ++i) tooNew.push_back(0xFF); // garbage where M would be + CHECK(requiredPrefixSize(tooNew) == std::uint64_t{16 + 5}); + + // Oversize length fields: stop reading. + std::vector overSemver; + overSemver.insert(overSemver.end(), kPackageMagic, kPackageMagic + 4); + wire::putLE(overSemver, std::uint32_t{1}); + wire::putLE(overSemver, std::uint32_t{1}); + wire::putLE(overSemver, kMaxWriterVersionBytes + 1); + CHECK(!requiredPrefixSize(overSemver).has_value()); + + std::vector overManifest = rawHeader(1, 1, "1.0.0"); + wire::putLE(overManifest, kMaxManifestBytes + 1); + CHECK(!requiredPrefixSize(overManifest).has_value()); +} + +int main() { + testEncodeDecodeRoundTrip(); + testEncodeRefusesWhatManifestRefuses(); + testTruncationAtEveryByteOffsetIsMalformed(); + testHeaderDefaultsMeanUnparsed(); + testTooNewProducesNoManifest(); + testAdditiveUnparseableManifestIsTooNew(); + testNewerAdditiveFormatReads(); + testHostileHeadersAreMalformed(); + testRequiredPrefixSizeRefusals(); + testRequiredPrefixSizeGrowsToTheFullPrefix(); + + if (g_fail == 0) { + std::printf("bank_package_tests: all passed\n"); + return 0; + } + std::printf("bank_package_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_capture_name.cpp b/tests/test_capture_name.cpp index 722c45b..da43228 100644 --- a/tests/test_capture_name.cpp +++ b/tests/test_capture_name.cpp @@ -241,6 +241,41 @@ static void testEveryAwkwardStemStaysFilesystemLegal() { } } +// --- captureTrackName ------------------------------------------------------- + +static void testCaptureTrackNamePrefixesAPlainSourceName() { + CHECK(captureTrackName("MONEY") == "Capture MONEY"); + CHECK(captureTrackName("bass di") == "Capture bass di"); +} + +static void testCaptureTrackNameIsIdempotent() { + // The whole point: a second render over a result track must not stack the prefix. + CHECK(captureTrackName("Capture MONEY") == "Capture MONEY"); + CHECK(captureTrackName(captureTrackName("MONEY")) == "Capture MONEY"); + // A fixed point on its own output for EVERY input, degenerate ones included. + for (const char* src : {"MONEY", "", "Capture", "Capture ", "Captured drums"}) { + const std::string once = captureTrackName(src); + CHECK(captureTrackName(once) == once); + } +} + +static void testCaptureTrackNameEmptySourceHasNoTrailingSpace() { + // Unreachable from trackName (GetTrackName always answers "Track N"), so this is + // the defensive case — a bare word rather than a name ending in a space. + CHECK(captureTrackName("") == "Capture"); +} + +static void testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN() { + // trackName's GetTrackName fallback rides in as an ordinary name. + CHECK(captureTrackName("Track 7") == "Capture Track 7"); +} + +static void testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord() { + // "Captured" begins with "Capture" but not with "Capture " — it is a different + // name and must be prefixed like any other. + CHECK(captureTrackName("Captured drums") == "Capture Captured drums"); +} + int main() { testStampIsZeroPaddedMonthDayHourMinute(); testUnsetStampProducesNoDiscriminator(); @@ -268,6 +303,12 @@ int main() { testOrdinalAndMultiSourceCompose(); testEveryAwkwardStemStaysFilesystemLegal(); + testCaptureTrackNamePrefixesAPlainSourceName(); + testCaptureTrackNameIsIdempotent(); + testCaptureTrackNameEmptySourceHasNoTrailingSpace(); + testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN(); + testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord(); + if (g_fail == 0) std::printf("capture_name: all tests passed\n"); else std::printf("capture_name: %d CHECK(s) FAILED\n", g_fail); return g_fail ? 1 : 0; diff --git a/tests/test_capture_paths.cpp b/tests/test_capture_paths.cpp index 3b1b95c..25e9640 100644 --- a/tests/test_capture_paths.cpp +++ b/tests/test_capture_paths.cpp @@ -362,6 +362,39 @@ static void testBankRelativeForNameMatchesDerivePathSpelling() { CHECK(bankRelativeForName(p.fileName) == p.relativePath); } +// --- deriveRenderPaths ------------------------------------------------------ + +static void testRenderPathsSpellTheStemExactlyAsTheBankPathDoes() { + // The one owner claim, made checkable: for the same baseName + uniqueTag, the + // bank path's stem and file name must BE the render path's. If these ever + // diverge, bankRelativeForName's exact-string match against an enumerated + // folder entry starts misfiring and prune misreads referenced files as orphans. + const BankPaths bank = deriveBankPaths("/proj", "kick drum!", "001"); + const RenderPaths render = deriveRenderPaths("/proj/reasampler_bank", + "kick drum!", "001"); + CHECK(render.fileStem == bank.fileStem); + CHECK(render.fileName == bank.fileName); + CHECK(render.absoluteDir == bank.absoluteDir); +} + +static void testRenderPathsTakeTheirDirectoryVerbatim() { + // No bank subfolder is appended — a render outside the bank has none, which is + // what makes "write into the bank folder" inexpressible through this call. + const RenderPaths r = deriveRenderPaths("/proj/media/", "take", ""); + CHECK(r.absoluteDir == normalizeSlashes("/proj/media")); + CHECK(r.fileName == "take.wav"); + CHECK(r.fileStem == "take"); + // Backslashes normalize and a trailing slash is stripped, same as everywhere. + CHECK(deriveRenderPaths("C:\\proj\\media\\", "take", "").absoluteDir == + normalizeSlashes("C:/proj/media")); +} + +static void testRenderPathsEmptyDirectoryStaysEmpty() { + // No CWD fallback: an unresolvable directory must fail at the caller's own + // guard, never silently render next to whatever the process happened to be in. + CHECK(deriveRenderPaths("", "take", "001").absoluteDir.empty()); +} + static void testBankRelativeForNameConventionAndEdge() { // The convention verbatim: "reasampler_bank/" (the one place the spelling lives). CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav"); @@ -396,6 +429,9 @@ int main() { testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps(); testTransitionInPlaceSaveIsNoOp(); testBankRelativeForNameMatchesDerivePathSpelling(); + testRenderPathsSpellTheStemExactlyAsTheBankPathDoes(); + testRenderPathsTakeTheirDirectoryVerbatim(); + testRenderPathsEmptyDirectoryStaysEmpty(); testBankRelativeForNameConventionAndEdge(); if (g_fail == 0) std::printf("capture_paths: all tests passed\n"); diff --git a/tests/test_export_bank.cpp b/tests/test_export_bank.cpp new file mode 100644 index 0000000..33a6c1b --- /dev/null +++ b/tests/test_export_bank.cpp @@ -0,0 +1,471 @@ +// Standalone tests for shell/package/export_bank — no REAPER, no framework. The +// export reads the session through inline accessors only, so a real ReaSamplerSession +// and a real bank folder on disk are both constructible here. +// +// Mid-stream failure is INJECTED rather than simulated: the digest pass and the +// stream pass are separate public calls, so a source file removed or rewritten +// between them is exactly the concurrent-edit case the stream pass re-checks for. + +#include "../src/shell/package/export_bank.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../src/core/capture/wav_codec.h" +#include "../src/core/model/bank_book.h" +#include "../src/core/package/bank_package.h" +#include "../src/shell/package/package_io.h" +#include "../src/shell/package/package_path.h" +#include "../src/shell/persist/session.h" + +using namespace reasampler; +namespace fs = std::filesystem; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- scratch filesystem ------------------------------------------------------- + +static std::string g_root; + +static std::string scratchRoot() { + if (g_root.empty()) { + std::error_code ec; + const fs::path p = fs::temp_directory_path(ec) / "reasampler_export_tests"; + fs::remove_all(p, ec); + fs::create_directories(p, ec); + g_root = p.generic_string(); + } + return g_root; +} + +static std::vector patternBytes(std::size_t n, std::uint8_t seed) { + std::vector v(n); + for (std::size_t i = 0; i < n; ++i) v[i] = static_cast(seed + i * 7u); + return v; +} + +static void writeFile(const std::string& path, const std::vector& bytes) { + std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +static std::vector readFile(const std::string& path) { + std::ifstream f(utf8Path(path), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +static bool exists(const std::string& path) { return fs::exists(utf8Path(path)); } + +// --- fixture project ---------------------------------------------------------- + +// One scratch project directory with a bank folder, plus the session whose book +// names its contents. `fileNames` are written into the bank folder with distinct +// byte patterns; a name in `omit` gets an index entry but NO file on disk. +struct Fixture { + std::string projectDir; + ReaSamplerSession session; + std::string bankId = "bank-1"; + + explicit Fixture(const std::string& tag) { + projectDir = scratchRoot() + "/" + tag; + std::error_code ec; + fs::create_directories(utf8Path(projectDir + "/reasampler_bank"), ec); + session.book().createBank(bankId, "Drums " + tag); + } + + void addSample(const std::string& id, const std::string& fileName, + std::size_t bytes, std::uint8_t seed, bool writeToDisk = true, + const std::string& displayName = "") { + model::Sample s; + s.id = id; + s.displayName = displayName.empty() ? id : displayName; + s.relativePath = std::string("reasampler_bank/") + fileName; + s.sampleRate = 48000; + s.channelCount = 2; + s.contentHash = "hash-" + id; + CHECK(session.book().index(bankId)->add(s) == model::AddResult::Added); + session.book().reconcileSlots(); + if (writeToDisk) writeFile(absPathOf(fileName), patternBytes(bytes, seed)); + } + + std::string absPathOf(const std::string& fileName) const { + return projectDir + "/reasampler_bank/" + fileName; + } + std::string destPath() const { return projectDir + "/out.rsbank"; } + + ExportRequest request() const { + ExportRequest req; + req.projectDir = projectDir; + req.bankId = bankId; + req.destAbsPath = destPath(); + req.exportTimestamp = 1234567890; + return req; + } +}; + +// --- package readback --------------------------------------------------------- + +// Decodes an emitted package straight off disk, growing the prefix read the way the +// format's own requiredPrefixSize seam asks callers to. +static package::DecodedPackage decodeFromDisk(const std::string& path) { + PackageFileReader reader(path); + const std::uint64_t size = reader.fileSize(); + std::vector prefix; + for (int guard = 0; guard < 8; ++guard) { + const std::optional need = package::requiredPrefixSize(prefix); + if (!need) break; + if (*need <= prefix.size()) break; + PayloadBuffer buf = reader.readRange(0, *need); + if (buf.empty()) break; + prefix.assign(buf.data(), buf.data() + buf.size()); + } + return package::decodePackage(prefix, size); +} + +// --- tests -------------------------------------------------------------------- + +static void testHealthyExportCarriesEveryPayloadByteExact() { + Fixture fx("healthy"); + fx.addSample("s1", "kick.wav", 800, 1); + fx.addSample("s2", "snare.wav", 1300, 60); + fx.addSample("s3", "hat.wav", 97, 200); + + const ExportOutcome out = exportBank(fx.session, fx.request()); + CHECK(out.status == ExportStatus::Written); + CHECK(out.entriesWritten == 3); + CHECK(out.excluded.empty()); + CHECK(PayloadBuffer::alive() == 0); + // Point-in-time alive() == 0 alone cannot fail on a whole-package-in-memory + // shape (N buffers allocated and freed one at a time still ends at 0); the + // high-water mark can, across this three-entry export and everything the test + // binary ran before it — it must never exceed the "at most one payload" claim. + CHECK(PayloadBuffer::highWaterMark() == 1); + + const package::DecodedPackage decoded = decodeFromDisk(fx.destPath()); + CHECK(decoded.status == package::PackageReadability::Readable); + CHECK(decoded.manifest.entries.size() == 3); + CHECK(decoded.layout.size() == 3); + CHECK(decoded.manifest.bankDisplayName == "Drums healthy"); + CHECK(decoded.manifest.exportTimestamp == 1234567890); + + // PER ENTRY, not in aggregate: the digest the package records, the digest of the + // payload actually stored at that entry's span, and the digest of the source file + // on disk must all be the same string. + PackageFileReader reader(fx.destPath()); + const std::vector sourceNames = {"kick.wav", "snare.wav", "hat.wav"}; + CHECK(decoded.manifest.entries.size() == sourceNames.size()); + for (std::size_t i = 0; i < decoded.manifest.entries.size(); ++i) { + const package::PackageEntry& entry = decoded.manifest.entries[i]; + const PayloadBuffer stored = reader.readRange(decoded.layout[i].offset, + decoded.layout[i].length); + CHECK(!stored.empty()); + const std::vector source = readFile(fx.absPathOf(sourceNames[i])); + const std::string sourceDigest = capture::hashBytes(source.data(), source.size()); + CHECK(entry.byteLength == source.size()); + CHECK(entry.byteHash == sourceDigest); + CHECK(capture::hashBytes(stored.data(), stored.size()) == sourceDigest); + CHECK(stored.size() == source.size()); + CHECK(std::equal(source.begin(), source.end(), stored.data())); + } + CHECK(PayloadBuffer::alive() == 0); +} + +// Every occurrence of `"key":"value"` in `json`, value returned raw (escape-aware +// only enough to not stop early on an escaped quote — this file's own writer output +// never nests an unescaped quote, so that is sufficient here). +static std::vector jsonStringValuesForKey(const std::string& json, + const std::string& key) { + std::vector values; + const std::string marker = "\"" + key + "\":\""; + std::size_t pos = 0; + while ((pos = json.find(marker, pos)) != std::string::npos) { + std::size_t i = pos + marker.size(); + while (i < json.size() && json[i] != '"') { + if (json[i] == '\\') ++i; // skip the escaped char too + ++i; + } + values.push_back(json.substr(pos + marker.size(), i - (pos + marker.size()))); + pos = i; + } + return values; +} + +// True for a value opening with an ':' drive-relative prefix (":" alone is +// JSON's own key separator, so this is checked on isolated VALUES, never on raw text). +static bool looksLikeDriveForm(const std::string& value) { + return value.size() >= 2 && std::isalpha(static_cast(value[0])) && + value[1] == ':'; +} + +static void testEmittedManifestBytesCarryNoPath() { + Fixture fx("nopath"); + // The bank's own display name AND a sample's displayName each carry a literal + // '/' — free text, unlike the entry `name` / nested `relativePath` fields this + // test actually polices (docs/product/bank-package.md:282-289: the destination is + // derived from the entry name, never from free text). Present in the fixture so + // the scan below proves it is scoped correctly rather than merely holding by + // accident on names that happen not to collide with the rule. + CHECK(fx.session.book().renameBank(fx.bankId, "Drums/Bus")); + // Both source records carry a directory component; neither may reach the file. + fx.addSample("s1", "kick.wav", 200, 3, /*writeToDisk=*/true, "Kick / alt take"); + fx.addSample("s2", "snare take 2.wav", 200, 9); + CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written); + + // Locate the MANIFEST REGION of the emitted file by walking the frozen header the + // way a reader does: magic | fv | minReader | len+semver | len+JSON. The binary + // length fields are deliberately excluded — a length whose byte happens to be + // 0x2F is not a separator. + const std::vector file = readFile(fx.destPath()); + CHECK(file.size() > 20); + auto le32 = [&](std::size_t at) { + return static_cast(file[at]) | + (static_cast(file[at + 1]) << 8) | + (static_cast(file[at + 2]) << 16) | + (static_cast(file[at + 3]) << 24); + }; + const std::uint32_t semverLen = le32(12); + const std::size_t manifestLenAt = 16 + semverLen; + const std::uint32_t manifestLen = le32(manifestLenAt); + const std::size_t manifestAt = manifestLenAt + 4; + CHECK(manifestAt + manifestLen <= file.size()); + const std::string manifest(reinterpret_cast(file.data() + manifestAt), + manifestLen); + CHECK(manifest.find("kick.wav") != std::string::npos); // the scan is looking at the manifest + + // The boundary, pinned rather than assumed: free text legitimately carries '/'. + CHECK(manifest.find("Drums/Bus") != std::string::npos); + CHECK(manifest.find("Kick / alt take") != std::string::npos); + + // The rule itself: scoped to the two fields the importer derives a destination + // from — the entry `name` and the nested Sample's own `relativePath` — never to + // `displayName` or the manifest's `bankDisplayName`. + const std::vector names = jsonStringValuesForKey(manifest, "name"); + const std::vector relPaths = jsonStringValuesForKey(manifest, "relativePath"); + CHECK(!names.empty()); + CHECK(!relPaths.empty()); + for (const std::string& v : names) { + CHECK(v.find('/') == std::string::npos); + CHECK(v.find('\\') == std::string::npos); + CHECK(v.find("..") == std::string::npos); + CHECK(!looksLikeDriveForm(v)); + } + for (const std::string& v : relPaths) { + CHECK(v.find('/') == std::string::npos); + CHECK(v.find('\\') == std::string::npos); + CHECK(v.find("..") == std::string::npos); + CHECK(!looksLikeDriveForm(v)); + } +} + +static void testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile() { + Fixture fx("midwrite"); + fx.addSample("s1", "kick.wav", 500, 1); + fx.addSample("s2", "snare.wav", 500, 2); + + const std::vector prior = patternBytes(64, 99); + writeFile(fx.destPath(), prior); + + ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId); + CHECK(survey.plan.verdict == package::ExportVerdict::Ready); + package::PackageManifest manifest = survey.plan.manifest; + const std::vector sources = {fx.absPathOf("kick.wav"), + fx.absPathOf("snare.wav")}; + std::string failed; + CHECK(digestSources(manifest, sources, failed)); + const std::optional encoded = package::encodePackage(manifest); + CHECK(encoded.has_value()); + + // Injection: the second payload vanishes after the framing that claims it was + // already encoded, so the failure lands with the prefix and one payload written. + std::error_code ec; + fs::remove(utf8Path(fx.absPathOf("snare.wav")), ec); + + const ExportOutcome out = + writePackageFile(*encoded, manifest, sources, fx.destPath()); + CHECK(out.status == ExportStatus::SourceReadFailed); + CHECK(out.offendingName == "snare.wav"); + CHECK(readFile(fx.destPath()) == prior); // the prior file is untouched + CHECK(!exists(fx.destPath() + ".rsbanktmp")); // and no debris is left behind + CHECK(PayloadBuffer::alive() == 0); +} + +static void testCommitFailureIsAGenuineMidWriteAbandon() { + // The mid-READ injection above (a source vanishing between digest and stream) is + // not what "mid-write" names in export_bank.cpp:115-118/122-125 — those guard a + // failure IN the write itself: appendPayload's stream going bad, or commit's + // rename failing. A directory squatting on the destination (test_package_io.cpp's + // own precedent for a real, not simulated, commit failure) makes every payload + // stream fine and only the final rename fail. + Fixture fx("commitfail"); + fx.addSample("s1", "kick.wav", 300, 1); + fx.addSample("s2", "snare.wav", 300, 2); + + std::error_code ec; + fs::create_directory(utf8Path(fx.destPath()), ec); + CHECK(!ec); + + ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId); + CHECK(survey.plan.verdict == package::ExportVerdict::Ready); + package::PackageManifest manifest = survey.plan.manifest; + const std::vector sources = {fx.absPathOf("kick.wav"), fx.absPathOf("snare.wav")}; + std::string failed; + CHECK(digestSources(manifest, sources, failed)); + const std::optional encoded = package::encodePackage(manifest); + CHECK(encoded.has_value()); + + const ExportOutcome out = writePackageFile(*encoded, manifest, sources, fx.destPath()); + CHECK(out.status == ExportStatus::WriteFailed); + CHECK(fs::is_directory(utf8Path(fx.destPath()))); // the squatting dir is untouched + CHECK(!exists(fx.destPath() + ".rsbanktmp")); // commit()'s own self-clean ran + CHECK(PayloadBuffer::alive() == 0); + + fs::remove(utf8Path(fx.destPath()), ec); +} + +static void testPayloadChangedBetweenDigestAndStreamAborts() { + Fixture fx("changed"); + fx.addSample("s1", "kick.wav", 500, 1); + CHECK(!exists(fx.destPath())); + + ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId); + package::PackageManifest manifest = survey.plan.manifest; + const std::vector sources = {fx.absPathOf("kick.wav")}; + std::string failed; + CHECK(digestSources(manifest, sources, failed)); + const std::optional encoded = package::encodePackage(manifest); + CHECK(encoded.has_value()); + + // Same length, different bytes — only the digest re-check can catch this. + writeFile(fx.absPathOf("kick.wav"), patternBytes(500, 77)); + + const ExportOutcome out = + writePackageFile(*encoded, manifest, sources, fx.destPath()); + CHECK(out.status == ExportStatus::SourceChanged); + CHECK(out.offendingName == "kick.wav"); + CHECK(!exists(fx.destPath())); +} + +static void testExportTouchesNoProjectState() { + Fixture fx("readonly"); + fx.addSample("s1", "kick.wav", 400, 5); + fx.addSample("s2", "snare.wav", 400, 6); + + // saveToActiveProject writes seven keys (shell/persist/ext_state_io.cpp): `banks`, + // the legacy-key clear, `view_state`, the tail setting, the tracking ledger, the + // version stamp, and the bank-generation counter. This asserts byte-identity of + // the three that have an in-memory string to diff (`banks`, `view_state`, the tail + // setting) plus bankGeneration() (the bank-generation-counter key IS its + // serialization). The legacy-key clear and the version stamp are session-external, + // nothing here to diff against. The tracking ledger has no public accessor to diff + // either, but needs none: exportBank/digestSources/writePackageFile all take the + // session by `const&`, and ReaSamplerSession::recordCreated — the ledger's one + // writer (session.h) — is non-const, so it is not reachable through this call at + // all; the compiler enforces "untouched" here rather than a runtime check proving it. + const std::string bankBookBefore = fx.session.book().serialize(); + const std::string viewBefore = fx.session.view().serialize(); + const std::string tailBefore = capture::serializeTailSetting(fx.session.tail()); + const std::int64_t generationBefore = fx.session.bankGeneration(); + + CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written); + + CHECK(fx.session.book().serialize() == bankBookBefore); + CHECK(fx.session.view().serialize() == viewBefore); + CHECK(capture::serializeTailSetting(fx.session.tail()) == tailBefore); + CHECK(fx.session.bankGeneration() == generationBefore); +} + +static void testEmptyBankExportsAsAValidZeroEntryPackage() { + Fixture fx("empty"); + const ExportOutcome out = exportBank(fx.session, fx.request()); + CHECK(out.status == ExportStatus::Written); + CHECK(out.entriesWritten == 0); + + const package::DecodedPackage decoded = decodeFromDisk(fx.destPath()); + CHECK(decoded.status == package::PackageReadability::Readable); + CHECK(decoded.manifest.entries.empty()); + CHECK(decoded.layout.empty()); + CHECK(decoded.manifest.bankDisplayName == "Drums empty"); + // The size proof is decodePackage's, and it ran against the real on-disk size. + CHECK(decoded.prefixSize == readFile(fx.destPath()).size()); +} + +static void testIncompleteBankRefusesUntilConfirmed() { + Fixture fx("incomplete"); + fx.addSample("s1", "kick.wav", 300, 1); + fx.addSample("s2", "gone.wav", 300, 2, /*writeToDisk=*/false); + + ExportRequest req = fx.request(); + const ExportOutcome refused = exportBank(fx.session, req); + CHECK(refused.status == ExportStatus::RefusedIncomplete); + CHECK(refused.excluded.size() == 1); + CHECK(refused.excluded[0].sampleId == "s2"); + CHECK(refused.excluded[0].reason == package::ExclusionReason::FileMissing); + CHECK(!exists(fx.destPath())); + + req.allowIncomplete = true; + const ExportOutcome allowed = exportBank(fx.session, req); + CHECK(allowed.status == ExportStatus::Written); + CHECK(allowed.entriesWritten == 1); + CHECK(allowed.excluded.size() == 1); // the report survives into the summary + CHECK(decodeFromDisk(fx.destPath()).manifest.entries.size() == 1); +} + +static void testExistingDestinationRefusesUntilConfirmed() { + Fixture fx("overwrite"); + fx.addSample("s1", "kick.wav", 300, 1); + const std::vector prior = patternBytes(32, 11); + writeFile(fx.destPath(), prior); + + ExportRequest req = fx.request(); + const ExportOutcome refused = exportBank(fx.session, req); + CHECK(refused.status == ExportStatus::RefusedDestinationExists); + CHECK(readFile(fx.destPath()) == prior); + + req.allowOverwrite = true; + CHECK(exportBank(fx.session, req).status == ExportStatus::Written); + CHECK(readFile(fx.destPath()) != prior); +} + +static void testUnknownBankAndUnsavedProjectAreNamedSeparately() { + Fixture fx("guards"); + ExportRequest req = fx.request(); + req.bankId = "no-such-bank"; + CHECK(exportBank(fx.session, req).status == ExportStatus::NoSuchBank); + + ExportRequest unsaved = fx.request(); + unsaved.projectDir.clear(); + CHECK(exportBank(fx.session, unsaved).status == ExportStatus::NoProjectDir); + CHECK(!exists(fx.destPath())); +} + +int main() { + testHealthyExportCarriesEveryPayloadByteExact(); + testEmittedManifestBytesCarryNoPath(); + testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile(); + testCommitFailureIsAGenuineMidWriteAbandon(); + testPayloadChangedBetweenDigestAndStreamAborts(); + testExportTouchesNoProjectState(); + testEmptyBankExportsAsAValidZeroEntryPackage(); + testIncompleteBankRefusesUntilConfirmed(); + testExistingDestinationRefusesUntilConfirmed(); + testUnknownBankAndUnsavedProjectAreNamedSeparately(); + + if (g_fail == 0) { + std::printf("export_bank_tests: all passed\n"); + return 0; + } + std::printf("export_bank_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_export_plan.cpp b/tests/test_export_plan.cpp new file mode 100644 index 0000000..3ccd784 --- /dev/null +++ b/tests/test_export_plan.cpp @@ -0,0 +1,290 @@ +// Standalone tests for reasampler::package::export_plan — no REAPER, no filesystem, +// no test framework. The planner's totality claim is the point: every input class +// (missing / unreadable / unrepresentable / zero / one) classifies here, and the +// names it produces are asserted against the codec's OWN predicates rather than +// against a hand-copied rule. + +#include "../src/core/package/export_plan.h" + +#include +#include +#include +#include +#include + +#include "../src/core/package/package_format.h" +#include "../src/core/package/package_manifest.h" + +using namespace reasampler::package; +using namespace reasampler::model; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- fixtures ---------------------------------------------------------------- + +static Sample sampleAt(const std::string& id, const std::string& relativePath) { + Sample s; + s.id = id; + s.displayName = id + " display"; + s.relativePath = relativePath; + s.sampleRate = 48000; + s.channelCount = 2; + s.contentHash = "0123456789abcdef"; + return s; +} + +static ExportCandidate present(const std::string& id, const std::string& rel) { + return ExportCandidate{sampleAt(id, rel), SourceFileState::Present}; +} + +static ExportCandidate withState(const std::string& id, const std::string& rel, + SourceFileState state) { + return ExportCandidate{sampleAt(id, rel), state}; +} + +static ExportInputs bankOf(std::vector candidates) { + ExportInputs in; + in.bankDisplayName = "Drums"; + in.candidates = std::move(candidates); + std::vector ids; + for (const ExportCandidate& c : in.candidates) ids.push_back(c.sample.id); + in.slots.resetDense(ids); + return in; +} + +static bool hasExclusion(const ExportPlan& p, const std::string& id, ExclusionReason why) { + for (const ExcludedEntry& e : p.excluded) + if (e.sampleId == id && e.reason == why) return true; + return false; +} + +// --- the four classification inputs the plan names --------------------------- + +static void testZeroSamplesIsAReadyEmptyPlan() { + const ExportPlan p = planExport(bankOf({})); + CHECK(p.verdict == ExportVerdict::Ready); + CHECK(p.manifest.entries.empty()); + CHECK(p.sourceRelativePaths.empty()); + CHECK(p.excluded.empty()); + CHECK(p.manifest.bankDisplayName == "Drums"); + CHECK(p.manifest.slots.empty()); +} + +static void testOneSamplePresentShips() { + const ExportPlan p = planExport(bankOf({present("s1", "reasampler_bank/kick.wav")})); + CHECK(p.verdict == ExportVerdict::Ready); + CHECK(p.manifest.entries.size() == 1); + CHECK(p.excluded.empty()); + CHECK(p.manifest.entries[0].fileName == "kick.wav"); + CHECK(p.manifest.entries[0].sample.id == "s1"); + // The source spelling survives only on the side channel; the transport record + // names the payload by its bare package name. + CHECK(p.sourceRelativePaths.size() == 1); + CHECK(p.sourceRelativePaths[0] == "reasampler_bank/kick.wav"); + CHECK(p.manifest.entries[0].sample.relativePath == "kick.wav"); +} + +static void testMissingFileIsIncompleteNotRefused() { + const ExportPlan p = planExport(bankOf({ + present("s1", "reasampler_bank/kick.wav"), + withState("s2", "reasampler_bank/gone.wav", SourceFileState::Missing), + })); + CHECK(p.verdict == ExportVerdict::Incomplete); + CHECK(p.manifest.entries.size() == 1); + CHECK(p.manifest.entries[0].sample.id == "s1"); + CHECK(p.excluded.size() == 1); + CHECK(hasExclusion(p, "s2", ExclusionReason::FileMissing)); + CHECK(p.excluded[0].relativePath == "reasampler_bank/gone.wav"); +} + +static void testUnreadableFileStaysDistinctFromMissing() { + const ExportPlan p = planExport(bankOf({ + withState("s1", "reasampler_bank/locked.wav", SourceFileState::Unreadable), + withState("s2", "reasampler_bank/gone.wav", SourceFileState::Missing), + })); + CHECK(p.verdict == ExportVerdict::Incomplete); + CHECK(p.manifest.entries.empty()); + CHECK(p.excluded.size() == 2); + CHECK(hasExclusion(p, "s1", ExclusionReason::FileUnreadable)); + CHECK(hasExclusion(p, "s2", ExclusionReason::FileMissing)); + CHECK(!hasExclusion(p, "s1", ExclusionReason::FileMissing)); +} + +static void testUnrepresentableRecordRefusesWholeExport() { + // A traversing nested path — the one thing an index record can carry that + // BankModel::add does not itself refuse. + const ExportPlan traversal = + planExport(bankOf({present("s1", "reasampler_bank/kick.wav"), + present("s2", "reasampler_bank/../evil.wav")})); + CHECK(traversal.verdict == ExportVerdict::Refused); + CHECK(hasExclusion(traversal, "s2", ExclusionReason::RecordUnrepresentable)); + CHECK(traversal.manifest.entries.size() == 1); // still reports what WOULD ship + + const ExportPlan emptyId = planExport(bankOf({present("", "reasampler_bank/kick.wav")})); + CHECK(emptyId.verdict == ExportVerdict::Refused); + + const ExportPlan absolute = + planExport(bankOf({present("s1", "C:/elsewhere/kick.wav")})); + CHECK(absolute.verdict == ExportVerdict::Refused); + CHECK(hasExclusion(absolute, "s1", ExclusionReason::RecordUnrepresentable)); + + // Refused outranks Incomplete: a corrupt record is not something the + // "export the present N" confirm can proceed past. + const ExportPlan both = planExport(bankOf({ + withState("s1", "reasampler_bank/gone.wav", SourceFileState::Missing), + present("s2", "reasampler_bank/../evil.wav"), + })); + CHECK(both.verdict == ExportVerdict::Refused); +} + +// --- transport names ---------------------------------------------------------- + +static void testHostileNamesAreRepairedNotRelayed() { + // Every one of these is a name the codec refuses and a filesystem somewhere + // produces honestly. + const std::vector hostile = { + "reasampler_bank/ki:ck?.wav", "reasampler_bank/a|bd\"e*f.wav", + "reasampler_bank/CON.wav", "reasampler_bank/nul", + "reasampler_bank/trailing .wav ", "reasampler_bank/dots...", + // A literal ".." COMPONENT is not a name to repair — it is a traversing + // record, and the Refused test above owns it. + "reasampler_bank/.....", "reasampler_bank/.", + std::string("reasampler_bank/bad\xC3.wav"), // truncated UTF-8 sequence + std::string("reasampler_bank/") + std::string(400, 'x') + ".wav", + }; + std::vector candidates; + for (std::size_t i = 0; i < hostile.size(); ++i) + candidates.push_back(present("s" + std::to_string(i), hostile[i])); + + const ExportPlan p = planExport(bankOf(candidates)); + CHECK(p.verdict == ExportVerdict::Ready); + CHECK(p.manifest.entries.size() == hostile.size()); + for (const PackageEntry& e : p.manifest.entries) { + CHECK(isValidEntryName(e.fileName)); + CHECK(isValidNestedSamplePath(e.sample.relativePath)); + } +} + +static void testUniqueNameSurvivesLongExtensionUnderflow() { + // insertSuffix computes room = kMaxEntryNameBytes - suffix.size() - ext.size() in + // size_t; an extension long enough that even a two-digit "_10" suffix pushes the + // sum past the cap must not wrap that subtraction. Ten same-named entries force + // the tenth collision into double digits against a 253-byte extension (253 + 3 = + // 256, one over kMaxEntryNameBytes). + const std::string hostileName = "a." + std::string(252, 'x'); // 254 bytes, otherwise valid + std::vector candidates; + for (int i = 0; i < 10; ++i) + candidates.push_back(present("s" + std::to_string(i), "reasampler_bank/" + hostileName)); + + const ExportPlan p = planExport(bankOf(candidates)); + CHECK(p.verdict == ExportVerdict::Ready); + CHECK(p.manifest.entries.size() == 10); + for (const PackageEntry& e : p.manifest.entries) CHECK(isValidEntryName(e.fileName)); + for (std::size_t i = 0; i < p.manifest.entries.size(); ++i) + for (std::size_t j = i + 1; j < p.manifest.entries.size(); ++j) + CHECK(!sameEntryName(p.manifest.entries[i].fileName, + p.manifest.entries[j].fileName)); +} + +static void testCaseFoldedCollisionsAreDisambiguated() { + const ExportPlan p = planExport(bankOf({ + present("s1", "reasampler_bank/Kick.wav"), + present("s2", "reasampler_bank/kick.wav"), + present("s3", "reasampler_bank/KICK.wav"), + })); + CHECK(p.verdict == ExportVerdict::Ready); + CHECK(p.manifest.entries.size() == 3); + for (std::size_t i = 0; i < p.manifest.entries.size(); ++i) + for (std::size_t j = i + 1; j < p.manifest.entries.size(); ++j) + CHECK(!sameEntryName(p.manifest.entries[i].fileName, + p.manifest.entries[j].fileName)); + CHECK(p.manifest.entries[0].fileName == "Kick.wav"); + // The suffix goes before the extension, so the payload keeps its type. + CHECK(p.manifest.entries[1].fileName == "kick_2.wav"); +} + +static void testSanitizeNeverReturnsANameTheCodecRefuses() { + const std::vector raws = { + "", ".", "..", "...", " ", "com1", "LPT9.WAV", "a/b", "a\\b", "C:evil", + std::string("\x01\x02\x03"), std::string(300, 'y'), + std::string("caf\xC3\xA9.wav"), // well-formed UTF-8 must survive intact + }; + for (const std::string& raw : raws) CHECK(isValidEntryName(sanitizeEntryName(raw))); + CHECK(sanitizeEntryName("caf\xC3\xA9.wav") == "caf\xC3\xA9.wav"); + CHECK(sanitizeEntryName("kick.wav") == "kick.wav"); +} + +// --- what the plan hands the codec ------------------------------------------- + +static void testPlannedManifestSatisfiesTheCodec() { + ExportPlan p = planExport(bankOf({ + present("s1", "reasampler_bank/Kick.wav"), + present("s2", "reasampler_bank/kick.wav"), + present("s3", "reasampler_bank/CON.wav"), + present("s4", std::string("reasampler_bank/caf\xC3\xA9 mix.wav")), + })); + // byteLength/byteHash are the shell's to measure; stand them in so the encode + // path under test is the naming, not the digest. + for (PackageEntry& e : p.manifest.entries) { + e.byteLength = 44; + e.byteHash = "aaaaaaaabbbbbbbb"; + } + const std::optional json = serializeManifest(p.manifest); + CHECK(json.has_value()); + if (json) { + const std::optional back = deserializeManifest(*json); + CHECK(back.has_value()); + if (back) CHECK(*back == p.manifest); + } +} + +static void testSlotsFollowMembership() { + ExportInputs in = bankOf({ + present("s1", "reasampler_bank/a.wav"), + withState("s2", "reasampler_bank/gone.wav", SourceFileState::Missing), + present("s3", "reasampler_bank/c.wav"), + }); + const ExportPlan p = planExport(in); + CHECK(p.manifest.slots.slotOf("s2") == -1); // an excluded id keeps no display position + CHECK(p.manifest.slots.slotOf("s1") >= 0); + CHECK(p.manifest.slots.slotOf("s3") >= 0); + CHECK(p.manifest.slots.size() == 2); +} + +static void testPlanIsDeterministic() { + const ExportInputs in = bankOf({ + present("s1", "reasampler_bank/Kick.wav"), + present("s2", "reasampler_bank/kick.wav"), + withState("s3", "reasampler_bank/gone.wav", SourceFileState::Missing), + }); + const ExportPlan a = planExport(in); + const ExportPlan b = planExport(in); + CHECK(a.verdict == b.verdict); + CHECK(a.manifest == b.manifest); + CHECK(a.sourceRelativePaths == b.sourceRelativePaths); + CHECK(a.excluded.size() == b.excluded.size()); +} + +int main() { + testZeroSamplesIsAReadyEmptyPlan(); + testOneSamplePresentShips(); + testMissingFileIsIncompleteNotRefused(); + testUnreadableFileStaysDistinctFromMissing(); + testUnrepresentableRecordRefusesWholeExport(); + testHostileNamesAreRepairedNotRelayed(); + testUniqueNameSurvivesLongExtensionUnderflow(); + testCaseFoldedCollisionsAreDisambiguated(); + testSanitizeNeverReturnsANameTheCodecRefuses(); + testPlannedManifestSatisfiesTheCodec(); + testSlotsFollowMembership(); + testPlanIsDeterministic(); + + if (g_fail == 0) { + std::printf("export_plan_tests: all passed\n"); + return 0; + } + std::printf("export_plan_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_fx_offline.cpp b/tests/test_fx_offline.cpp new file mode 100644 index 0000000..dacbf63 --- /dev/null +++ b/tests/test_fx_offline.cpp @@ -0,0 +1,253 @@ +// Standalone tests for reasampler's per-FX offline restore resolution — no +// REAPER, no test framework. +// +// The property under test: a captured FX-offline state lands on the FX it was +// captured FROM, whatever that FX's slot has become while the track was parked. +// Every scenario below is a chain mutation performed while parked. + +#include "../src/core/view/fx_offline.h" + +#include +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// -- helpers ----------------------------------------------------------------- + +// The ops the restore planner emits for one track from an identity-keyed +// snapshot: capture-order entries, each carrying the FX's own identity. +static std::vector identityOps( + const std::vector>& captured) { + std::vector ops; + for (std::size_t i = 0; i < captured.size(); ++i) { + ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, captured[i].first, + static_cast(i), captured[i].second}); + } + return ops; +} + +// The ops a snapshot lifted from a pre-identity project yields: no identities, +// position is the slot. +static std::vector slotOps(const std::vector& captured) { + std::vector ops; + for (std::size_t i = 0; i < captured.size(); ++i) { + ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Slot, {}, + static_cast(i), captured[i]}); + } + return ops; +} + +static bool hasWrite(const FxRestoreResolution& res, int fxIndex, bool offline) { + for (const FxOfflineWrite& w : res.writes) + if (w.fxIndex == fxIndex) return w.offline == offline; + return false; +} + +static bool writesTouch(const FxRestoreResolution& res, int fxIndex) { + for (const FxOfflineWrite& w : res.writes) + if (w.fxIndex == fxIndex) return true; + return false; +} + +// -- 1. Chain reordered while parked ----------------------------------------- + +static void testReorderedChainRestoresEachPluginItsOwnState() { + // Captured with A, B, C in slots 0,1,2 — B was already offline pre-park. + const std::vector ops = + identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}}); + + // While parked the user dragged C to the front: the chain is now C, A, B. + const FxRestoreResolution res = resolveFxRestore(ops, {"{C}", "{A}", "{B}"}); + + CHECK(res.writes.size() == 3); + CHECK(res.drops.total() == 0); + CHECK(hasWrite(res, 1, false)); // A, now at slot 1 + CHECK(hasWrite(res, 2, true)); // B's offline state followed B to slot 2 + CHECK(hasWrite(res, 0, false)); // C, now at slot 0 + + // The slot-keyed reading of the SAME capture is what the old code did: it + // would have written B's `true` to slot 1, which is now A. Pinning the + // divergence keeps a well-meant "just use the index" from coming back. + const FxRestoreResolution bySlot = + resolveFxRestore(slotOps({false, true, false}), {"{C}", "{A}", "{B}"}); + CHECK(hasWrite(bySlot, 1, true)); // the defect, reproduced deliberately +} + +// -- 2. FX deleted while parked ---------------------------------------------- + +static void testDeletedFxDropsExplicitlyAndTouchesNothingElse() { + // B (true) and C (false) carry OPPOSITE captured states — the discriminator. + // A test where both carried `true` couldn't tell "C's own state followed it + // to slot 1" apart from "B's dropped state leaked onto whatever moved there". + const std::vector ops = + identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}}); + + // B was deleted while parked; A and C closed the gap. + const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{C}"}); + + CHECK(res.writes.size() == 2); + CHECK(res.drops.missingIdentity == 1); + CHECK(res.drops.unidentified == 0); + CHECK(res.drops.slotOutOfRange == 0); + CHECK(hasWrite(res, 0, false)); // A + // C's OWN captured `false` landed at slot 1, not B's dropped `true`. + CHECK(hasWrite(res, 1, false)); + + // The drop is reportable, not silent. + const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1); + CHECK(!msg.empty()); + CHECK(msg.find("1 captured FX offline state(s) on 1 track(s)") != std::string::npos); + CHECK(msg.find("no longer in the chain") != std::string::npos); + CHECK(msg.back() == '\n'); + + // Nothing dropped ⇒ nothing said. + CHECK(describeFxRestoreDrops(FxRestoreDrops{}, 0).empty()); +} + +static void testDescribeNamesAllThreeDropKinds() { + FxRestoreDrops drops; + drops.missingIdentity = 2; + drops.unidentified = 1; + drops.slotOutOfRange = 3; + CHECK(drops.total() == 6); + + const std::string msg = describeFxRestoreDrops(drops, /*trackCount=*/2); + CHECK(msg.find("6 captured FX offline state(s) on 2 track(s)") != std::string::npos); + CHECK(msg.find("2 FX no longer in the chain") != std::string::npos); + CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos); + CHECK(msg.find("3 from a project saved before FX identity was recorded") + != std::string::npos); +} + +// -- 3. FX added while parked ------------------------------------------------- + +static void testAddedFxIsNotTouched() { + const std::vector ops = identityOps({{"{A}", false}, {"{B}", true}}); + + // D was inserted at the FRONT while parked — the case where an index-keyed + // restore would have written every captured state onto the wrong plugin. + const FxRestoreResolution res = resolveFxRestore(ops, {"{D}", "{A}", "{B}"}); + + CHECK(res.writes.size() == 2); + CHECK(res.drops.total() == 0); + CHECK(hasWrite(res, 1, false)); // A + CHECK(hasWrite(res, 2, true)); // B + CHECK(!writesTouch(res, 0)); // D is never written at all +} + +// -- 4. Two instances of the SAME plugin type -------------------------------- + +static void testTwoInstancesOfOnePluginKeyIndependently() { + // Two copies of one plugin: distinct instances, distinct identities, and the + // two carry OPPOSITE captured states — a scheme keyed on plugin type or name + // could not tell them apart and would restore both the same way. + const std::vector ops = + identityOps({{"{EQ-1}", true}, {"{EQ-2}", false}, {"{COMP}", false}}); + + // Swapped while parked: EQ-2, COMP, EQ-1. + const FxRestoreResolution res = resolveFxRestore(ops, {"{EQ-2}", "{COMP}", "{EQ-1}"}); + + CHECK(res.writes.size() == 3); + CHECK(res.drops.total() == 0); + CHECK(hasWrite(res, 2, true)); // EQ-1's offline=true followed it to slot 2 + CHECK(hasWrite(res, 0, false)); // EQ-2 stayed online at slot 0 + CHECK(hasWrite(res, 1, false)); // COMP +} + +// -- 5. Slot-keyed (pre-identity) snapshots ---------------------------------- + +static void testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks() { + // All a pre-identity blob's bytes can support: position addressing. + const FxRestoreResolution res = + resolveFxRestore(slotOps({false, true}), {"{A}", "{B}", "{C}"}); + CHECK(res.writes.size() == 2); + CHECK(res.drops.total() == 0); + CHECK(hasWrite(res, 0, false)); + CHECK(hasWrite(res, 1, true)); + CHECK(!writesTouch(res, 2)); // an FX the snapshot never covered stays untouched + + // A slot that no longer exists is dropped and counted, never clamped. + const FxRestoreResolution shrunk = + resolveFxRestore(slotOps({false, true, true}), {"{A}"}); + CHECK(shrunk.writes.size() == 1); + CHECK(shrunk.drops.slotOutOfRange == 2); + CHECK(shrunk.drops.missingIdentity == 0); +} + +// -- 6. Degenerate inputs ----------------------------------------------------- + +static void testUnresolvableIdentityNeverFallsBackToItsSlot() { + // An identity-keyed entry with NO identity (REAPER reported none at capture) + // is a drop — the slot it happens to carry must not be used as a substitute. + // Counted as `unidentified`, not `missingIdentity`: unlike a real captured + // identity going missing, this FX may still be sitting right there — we + // simply never had a name for it, and the report must say that, not "no + // longer in the chain". + std::vector ops = identityOps({{"{A}", false}}); + ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, "", /*slot=*/1, /*offline=*/true}); + + const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{B}"}); + CHECK(res.writes.size() == 1); + CHECK(hasWrite(res, 0, false)); + CHECK(!writesTouch(res, 1)); // {B} would have been the slot-1 victim + CHECK(res.drops.missingIdentity == 0); + CHECK(res.drops.unidentified == 1); + + const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1); + CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos); + CHECK(msg.find("no longer in the chain") == std::string::npos); // not this FX's story +} + +static void testLiveFxWithNoIdentityIsNeverARestoreTarget() { + // The mirror case: a live FX REAPER reports no GUID for cannot be matched by + // an empty captured identity either. + const FxRestoreResolution res = + resolveFxRestore(identityOps({{"{A}", true}}), {"", "{A}"}); + CHECK(res.writes.size() == 1); + CHECK(hasWrite(res, 1, true)); + CHECK(!writesTouch(res, 0)); + CHECK(res.drops.total() == 0); +} + +static void testEmptyInputsProduceNoWrites() { + CHECK(resolveFxRestore({}, {"{A}"}).writes.empty()); + CHECK(resolveFxRestore({}, {}).drops.total() == 0); + + // Every FX gone (the whole chain cleared while parked): all dropped, none written. + const FxRestoreResolution res = + resolveFxRestore(identityOps({{"{A}", true}, {"{B}", false}}), {}); + CHECK(res.writes.empty()); + CHECK(res.drops.missingIdentity == 2); +} + +static void testDuplicateLiveIdentityResolvesToTheFirstSlotOnly() { + // Not producible by REAPER (one GUID per instance) — pinned so a corrupt or + // hand-edited chain writes once, deterministically, instead of twice. + const FxRestoreResolution res = + resolveFxRestore(identityOps({{"{A}", true}}), {"{A}", "{A}"}); + CHECK(res.writes.size() == 1); + CHECK(hasWrite(res, 0, true)); + CHECK(!writesTouch(res, 1)); +} + +int main() { + testReorderedChainRestoresEachPluginItsOwnState(); + testDeletedFxDropsExplicitlyAndTouchesNothingElse(); + testDescribeNamesAllThreeDropKinds(); + testAddedFxIsNotTouched(); + testTwoInstancesOfOnePluginKeyIndependently(); + testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks(); + testUnresolvableIdentityNeverFallsBackToItsSlot(); + testLiveFxWithNoIdentityIsNeverARestoreTarget(); + testEmptyInputsProduceNoWrites(); + testDuplicateLiveIdentityResolvesToTheFirstSlotOnly(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_import_landing.cpp b/tests/test_import_landing.cpp new file mode 100644 index 0000000..441002c --- /dev/null +++ b/tests/test_import_landing.cpp @@ -0,0 +1,396 @@ +// Standalone tests for shell/package/import_landing — no REAPER, no framework, real +// package bytes on a real filesystem. Packages are FRAMED BY HAND (not by +// encodePackage) so the version-ladder suites can dial formatVersion and +// minReaderVersion independently, and so an encode-side regression cannot hide the +// import's behaviour from itself. + +#include "../src/shell/package/import_landing.h" + +#include +#include +#include +#include +#include +#include + +#include "../src/core/capture/wav_codec.h" +#include "../src/core/package/bank_package.h" +#include "../src/core/tracking/origin_ledger.h" +#include "../src/core/version/app_version.h" +#include "../src/shell/package/package_path.h" + +using namespace reasampler; +using namespace reasampler::package; +using reasampler::model::Sample; +namespace fs = std::filesystem; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static const char* kTag = "1754000000"; +static const char* kBankId = "import-test-bank"; + +// --- scratch project --------------------------------------------------------- + +// One project directory per suite, torn down after, so no suite can observe another's +// bank folder in listFolderFileNames. +class Scratch { +public: + explicit Scratch(const std::string& name) + : dir_(pathToUtf8(fs::current_path() / utf8Path("import_scratch_" + name))) { + std::error_code ec; + fs::remove_all(utf8Path(dir_), ec); + fs::create_directories(utf8Path(dir_), ec); + } + ~Scratch() { + std::error_code ec; + fs::remove_all(utf8Path(dir_), ec); + } + const std::string& projectDir() const { return dir_; } + std::string bankDir() const { return bankFolderDir(dir_); } + std::string packagePath() const { return dir_ + "/bank.rsbank"; } + + std::vector bankFiles() const { + std::vector out; + std::error_code ec; + for (const auto& e : fs::directory_iterator(utf8Path(bankDir()), ec)) + if (e.is_regular_file(ec)) out.push_back(pathToUtf8(e.path().filename())); + return out; + } + +private: + std::string dir_; +}; + +static void writeBytes(const std::string& path, const std::vector& bytes) { + std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +static std::vector readBytes(const std::string& path) { + std::ifstream f(utf8Path(path), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +// --- hand-rolled package framing -------------------------------------------- + +static void putU32(std::vector& out, std::uint32_t v) { + for (int b = 0; b < 4; ++b) out.push_back(static_cast((v >> (b * 8)) & 0xFFu)); +} + +static std::vector frame(std::uint32_t formatVersion, + std::uint32_t minReaderVersion, + const std::string& writerSemver, + const std::string& manifestJson, + const std::vector>& payloads) { + std::vector out(kPackageMagic, kPackageMagic + 4); + putU32(out, formatVersion); + putU32(out, minReaderVersion); + putU32(out, static_cast(writerSemver.size())); + out.insert(out.end(), writerSemver.begin(), writerSemver.end()); + putU32(out, static_cast(manifestJson.size())); + out.insert(out.end(), manifestJson.begin(), manifestJson.end()); + for (const auto& p : payloads) out.insert(out.end(), p.begin(), p.end()); + return out; +} + +static std::vector payloadOf(std::size_t n, std::uint8_t seed) { + std::vector v(n); + for (std::size_t i = 0; i < n; ++i) v[i] = static_cast(seed + i * 13u); + return v; +} + +struct Fixture { + PackageManifest manifest; + std::vector> payloads; +}; + +static void addEntry(Fixture& f, const std::string& fileName, const std::string& id, + const std::string& contentHash, std::uint8_t seed) { + std::vector payload = payloadOf(48 + seed, seed); + PackageEntry e; + e.fileName = fileName; + e.byteLength = payload.size(); + e.byteHash = capture::hashBytes(payload.data(), payload.size()); + e.sample.id = id; + e.sample.displayName = id; + e.sample.relativePath = "reasampler_bank/" + fileName; + e.sample.contentHash = contentHash; + f.manifest.entries.push_back(std::move(e)); + f.payloads.push_back(std::move(payload)); +} + +static Fixture twoEntryFixture(const std::string& bankName) { + Fixture f; + f.manifest.bankDisplayName = bankName; + addEntry(f, "kick.wav", "cap-kick", "h-kick", 1); + addEntry(f, "snare.wav", "cap-snare", "h-snare", 2); + return f; +} + +// Frames `f` with this build's own ladder pair unless overridden. +static std::vector packageBytes(const Fixture& f, + std::uint32_t formatVersion = kPackageFormatVersion, + std::uint32_t minReader = kPackageMinReaderVersion, + const std::string& manifestOverride = {}) { + const auto json = serializeManifest(f.manifest); + const std::string body = manifestOverride.empty() ? *json : manifestOverride; + return frame(formatVersion, minReader, version::stampVersion(), body, f.payloads); +} + +// --- the sequence the verb runs, minus REAPER -------------------------------- + +struct RunResult { + ImportLanding landing; + bool applied = false; + tracking::OriginLedger ledger; +}; + +static RunResult runImport(const Scratch& scratch, BankBook& book, + const std::string& bankId = kBankId) { + RunResult r; + LandedFileJournal journal; + r.landing = landPackage(scratch.packagePath(), scratch.projectDir(), book, kTag, journal); + if (r.landing.outcome != ImportOutcome::Landed) return r; + r.applied = applyImportedBank(book, bankId, r.landing.plan, [&r](const Sample& s) { + tracking::OriginRecord rec; + rec.relativePath = s.relativePath; + rec.kind = tracking::OriginKind::PackageImport; + rec.sampleId = s.id; + r.ledger.record(rec); + }); + if (r.applied) journal.markIndexCommitted(); + return r; +} + +// --- suites ------------------------------------------------------------------ + +static void testCleanImportLandsEveryPayloadByteExact() { + Scratch scratch("clean"); + const Fixture f = twoEntryFixture("Drums"); + writeBytes(scratch.packagePath(), packageBytes(f)); + + BankBook book; + const RunResult r = runImport(scratch, book); + + CHECK(r.landing.outcome == ImportOutcome::Landed); + CHECK(r.applied); + CHECK(scratch.bankFiles().size() == 2); + for (std::size_t i = 0; i < 2; ++i) { + const std::string name = r.landing.plan.entries[i].destFileName; + CHECK(readBytes(scratch.bankDir() + "/" + name) == f.payloads[i]); + } + const Bank* bank = book.bank(kBankId); + CHECK(bank != nullptr && bank->displayName == "Drums"); + CHECK(bank->index.size() == 2); + CHECK(PayloadBuffer::alive() == 0); +} + +static void testEveryLandedFileHasAPackageImportBirthRecord() { + Scratch scratch("births"); + const Fixture f = twoEntryFixture("Drums"); + writeBytes(scratch.packagePath(), packageBytes(f)); + + BankBook book; + const RunResult r = runImport(scratch, book); + CHECK(r.applied); + + // Read the ledger back rather than counting calls. + CHECK(r.ledger.size() == 2); + for (const std::string& name : scratch.bankFiles()) { + const tracking::OriginRecord* rec = + r.ledger.find("reasampler_bank/" + name); + CHECK(rec != nullptr); + if (rec) CHECK(rec->kind == tracking::OriginKind::PackageImport); + } +} + +static void testACollapsedEntryLeavesNoFileBehind() { + Scratch scratch("collapse"); + Fixture f; + f.manifest.bankDisplayName = "Drums"; + addEntry(f, "kick.wav", "cap-a", "same-hash", 1); + addEntry(f, "kick_copy.wav", "cap-b", "same-hash", 2); + writeBytes(scratch.packagePath(), packageBytes(f)); + + BankBook book; + const RunResult r = runImport(scratch, book); + CHECK(r.landing.outcome == ImportOutcome::Landed); + // One file, one entry, one birth record — the dedup never manufactured an orphan. + CHECK(scratch.bankFiles().size() == 1); + CHECK(book.bank(kBankId)->index.size() == 1); + CHECK(r.ledger.size() == 1); +} + +static void testHashMismatchLandsNothingAndMutatesNothing() { + Scratch scratch("integrity"); + const Fixture f = twoEntryFixture("Drums"); + std::vector bytes = packageBytes(f); + bytes.back() ^= 0xFFu; // corrupt the LAST entry's payload + writeBytes(scratch.packagePath(), bytes); + + BankBook book; + const BankBook before = book; + const RunResult r = runImport(scratch, book); + + CHECK(r.landing.outcome == ImportOutcome::IntegrityFailed); + CHECK(r.landing.failedEntryName == "snare.wav"); + // Refused BEFORE landing anything, not landed-then-rolled-back: the bank folder is + // created on the way into the write loop, so its absence dates the refusal. + CHECK(!fs::exists(utf8Path(scratch.bankDir()))); + CHECK(r.landing.rollback.deletedCount == 0); + CHECK(book == before); // zero index mutation + CHECK(!r.applied); +} + +static void testWriteFailureAtEntryTwoRollsBackEntryOne() { + Scratch scratch("rollback"); + const Fixture f = twoEntryFixture("Drums"); + writeBytes(scratch.packagePath(), packageBytes(f)); + + // Occupy the second entry's destination with a DIRECTORY: listFolderFileNames sees + // regular files only, so the plan does not rename around it, and the exclusive + // create then fails exactly where the injection wants it. + std::error_code ec; + fs::create_directories(utf8Path(scratch.bankDir()), ec); + fs::create_directory(utf8Path(scratch.bankDir() + "/snare.wav"), ec); + + BankBook book; + const BankBook before = book; + const RunResult r = runImport(scratch, book); + + CHECK(r.landing.outcome == ImportOutcome::WriteFailed); + CHECK(r.landing.failedEntryName == "snare.wav"); + CHECK(r.landing.rollback.deletedCount == 1); // entry one was rolled back + CHECK(scratch.bankFiles().empty()); // nothing from this import survives + CHECK(book == before); + CHECK(PayloadBuffer::alive() == 0); +} + +static void testTooNewRefusesWholeAndNamesTheWriter() { + Scratch scratch("toonew"); + const Fixture f = twoEntryFixture("Drums"); + writeBytes(scratch.packagePath(), + packageBytes(f, kPackageFormatVersion + 1, kPackageFormatVersion + 1)); + + BankBook book; + const BankBook before = book; + const RunResult r = runImport(scratch, book); + + CHECK(r.landing.outcome == ImportOutcome::TooNew); + // All three facts the refusal must name are available from the landing. + CHECK(r.landing.header.minReaderVersion == kPackageFormatVersion + 1); + CHECK(r.landing.header.writerVersion == version::stampVersion()); + // The refusal returns before the bank folder is even created. + CHECK(!fs::exists(utf8Path(scratch.bankDir()))); + CHECK(book == before); +} + +static void testNewerFormatVersionStillImportsWhenTheReaderIsReachable() { + Scratch scratch("additive"); + const Fixture f = twoEntryFixture("Drums"); + // An additive newer writer: formatVersion moved, minReaderVersion did not, and the + // manifest carries a key this build has never heard of. + std::string json = *serializeManifest(f.manifest); + CHECK(!json.empty() && json.front() == '{'); + json.insert(1, "\"futureKey\":{\"nested\":[1,2,3]},"); + writeBytes(scratch.packagePath(), + packageBytes(f, kPackageFormatVersion + 1, kPackageMinReaderVersion, json)); + + BankBook book; + const RunResult r = runImport(scratch, book); + + CHECK(r.landing.outcome == ImportOutcome::Landed); + CHECK(r.landing.header.formatVersion == kPackageFormatVersion + 1); + CHECK(book.bank(kBankId)->index.size() == 2); + CHECK(scratch.bankFiles().size() == 2); +} + +static void testTruncationAndGarbageReportMalformedNotTooNew() { + { + Scratch scratch("truncated"); + const Fixture f = twoEntryFixture("Drums"); + std::vector bytes = packageBytes(f); + bytes.pop_back(); + writeBytes(scratch.packagePath(), bytes); + + BankBook book; + const BankBook before = book; + const RunResult r = runImport(scratch, book); + CHECK(r.landing.outcome == ImportOutcome::Malformed); + CHECK(book == before); + } + { + Scratch scratch("garbage"); + writeBytes(scratch.packagePath(), payloadOf(200, 9)); + BankBook book; + CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Malformed); + } +} + +static void testMissingPackageFileIsUnreadableNotMalformed() { + Scratch scratch("absent"); + BankBook book; + CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Unreadable); +} + +static void testUnsavedProjectRefusesBeforeAnythingIsRead() { + Scratch scratch("noproject"); + writeBytes(scratch.packagePath(), packageBytes(twoEntryFixture("Drums"))); + BankBook book; + LandedFileJournal journal; + const ImportLanding landing = + landPackage(scratch.packagePath(), std::string{}, book, kTag, journal); + CHECK(landing.outcome == ImportOutcome::NoProject); +} + +static void testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal() { + Scratch scratch("roundtrip"); + const Fixture f = twoEntryFixture("B"); + writeBytes(scratch.packagePath(), packageBytes(f)); + + // A project that already holds bank "B" with the package's own entries. + BankBook book; + book.createBank("bank-b", "B"); + for (const PackageEntry& e : f.manifest.entries) book.index("bank-b")->add(e.sample); + const BankModel originalB = *book.index("bank-b"); + + const RunResult second = runImport(scratch, book, "bank-b2"); + CHECK(second.landing.outcome == ImportOutcome::Landed); + CHECK(book.bank("bank-b2")->displayName == "B 2"); + CHECK(book.bank("bank-b2")->index.size() == 2); + CHECK(*book.index("bank-b") == originalB); // B itself unmutated + for (const Sample& s : book.index("bank-b2")->all()) { + CHECK(s.id.rfind(kImportIdPrefix, 0) == 0); + CHECK(s.id != "cap-kick" && s.id != "cap-snare"); + } + + const RunResult third = runImport(scratch, book, "bank-b3"); + CHECK(third.landing.outcome == ImportOutcome::Landed); + CHECK(book.bank("bank-b3")->displayName == "B 3"); + CHECK(*book.index("bank-b") == originalB); + // Four distinct files: two per re-import, never overwritten. Bank "B"'s own two + // entries were seeded index-only above (book.index("bank-b")->add), never written + // to disk, so they don't add to this count. + CHECK(scratch.bankFiles().size() == 4); +} + +int main() { + testCleanImportLandsEveryPayloadByteExact(); + testEveryLandedFileHasAPackageImportBirthRecord(); + testACollapsedEntryLeavesNoFileBehind(); + testHashMismatchLandsNothingAndMutatesNothing(); + testWriteFailureAtEntryTwoRollsBackEntryOne(); + testTooNewRefusesWholeAndNamesTheWriter(); + testNewerFormatVersionStillImportsWhenTheReaderIsReachable(); + testTruncationAndGarbageReportMalformedNotTooNew(); + testMissingPackageFileIsUnreadableNotMalformed(); + testUnsavedProjectRefusesBeforeAnythingIsRead(); + testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal(); + + if (g_fail == 0) std::printf("import_landing: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_import_plan.cpp b/tests/test_import_plan.cpp new file mode 100644 index 0000000..2f60977 --- /dev/null +++ b/tests/test_import_plan.cpp @@ -0,0 +1,424 @@ +// Standalone tests for reasampler::package::import_plan — no REAPER, no filesystem, +// no test framework. Every one of the four collision classes (sample id, bank-folder +// file name, content hash, bank display name) is exercised here, which is the point of +// the module: the whole collision rule set is decidable from strings and hashes. + +#include "../src/core/package/import_plan.h" + +#include +#include +#include + +#include "../src/core/tracking/tracking_authority.h" + +using namespace reasampler; +using namespace reasampler::package; +using reasampler::model::Sample; +using reasampler::model::SlotMap; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static const char* kProjectDir = "/proj"; +static const char* kTag = "1754000000"; + +// --- fixtures ---------------------------------------------------------------- + +static PackageEntry entry(const std::string& fileName, const std::string& id, + const std::string& hash) { + PackageEntry e; + e.fileName = fileName; + e.byteLength = 64; + e.byteHash = "0011223344556677"; + e.sample.id = id; + e.sample.displayName = id; + e.sample.relativePath = "reasampler_bank/" + fileName; + e.sample.contentHash = hash; + return e; +} + +static PackageManifest manifestOf(std::vector entries, + const std::string& bankName) { + PackageManifest m; + m.bankDisplayName = bankName; + m.entries = std::move(entries); + return m; +} + +// A book carrying the named banks, in order, each with a caller-supplied id. +static BankBook bookWithBanks(const std::vector& names) { + BankBook book; + for (std::size_t i = 0; i < names.size(); ++i) + book.createBank("bank-" + std::to_string(i), names[i]); + return book; +} + +static const PlannedEntry& landed(const ImportPlan& plan, std::size_t manifestIndex) { + return plan.entries[manifestIndex]; +} + +// --- the bank-name probe (collision class 4) --------------------------------- + +static std::string plannedName(const std::vector& existingBanks, + const std::string& packageBankName) { + const BankBook book = bookWithBanks(existingBanks); + return planImport(manifestOf({}, packageBankName), book, kProjectDir, {}, kTag) + .bankDisplayName; +} + +static void testFreeSeedIsKeptVerbatim() { + CHECK(plannedName({"Percussion"}, "Drums") == "Drums"); + const ImportPlan plan = + planImport(manifestOf({}, "Drums"), bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(!plan.bankNameAdjusted); + CHECK(plan.seedBankName == "Drums"); +} + +static void testFoldedCollisionTakesTheFirstSuffix() { + // The book's fold is case- and whitespace-insensitive, so "drums" blocks "Drums". + CHECK(plannedName({"drums"}, "Drums") == "Drums 2"); + CHECK(plannedName({" DRUMS "}, "Drums") == "Drums 2"); + + const ImportPlan plan = + planImport(manifestOf({}, "Drums"), bookWithBanks({"drums"}), kProjectDir, {}, kTag); + CHECK(plan.bankNameAdjusted); + CHECK(plan.seedBankName == "Drums"); // the message needs what was asked for +} + +static void testProbeFillsAGap() { + // First-free-ascending, not highest-plus-one: "Drums 2" is free, so it wins. + CHECK(plannedName({"Drums", "Drums 3"}, "Drums") == "Drums 2"); +} + +static void testSeedIsNeverReparsed() { + // "Drums 2" colliding lands as "Drums 2 2", NOT "Drums 3" — a bare trailing integer + // cannot be told from a name the user wrote. + CHECK(plannedName({"Drums 2"}, "Drums 2") == "Drums 2 2"); + CHECK(plannedName({"Kit 808"}, "Kit 808") == "Kit 808 2"); +} + +static void testBlankRecordedNameFallsBackToTheDefault() { + CHECK(plannedName({}, "") == kDefaultImportBankName); + CHECK(plannedName({}, " \t ") == kDefaultImportBankName); + // And the fallback is a seed like any other, so a second one suffixes. + CHECK(plannedName({kDefaultImportBankName}, "") == + std::string(kDefaultImportBankName) + " 2"); +} + +static void testPoolExportLandsAsANamedBank() { + // The destination's pool always exists and always carries the protected name + // "Pool", so a pool export imports as a NAMED bank "Pool 2" — intended, not a glitch. + CHECK(plannedName({}, "Pool") == "Pool 2"); + const ImportPlan plan = + planImport(manifestOf({}, "Pool"), bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(plan.bankNameAdjusted); +} + +static void testRepeatedImportsWalkTheSuffixUpwards() { + CHECK(plannedName({"B"}, "B") == "B 2"); + CHECK(plannedName({"B", "B 2"}, "B") == "B 3"); +} + +// --- sample ids (collision class 1) ------------------------------------------ + +static void testEveryIdIsRemintedUnderTheImportPrefix() { + const PackageManifest m = manifestOf({entry("kick.wav", "cap-1-kick.wav", "h1"), + entry("snare.wav", "cap-2-snare.wav", "h2")}, + "Drums"); + const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag); + + CHECK(plan.landCount == 2); + for (const PlannedEntry& e : plan.entries) { + CHECK(e.sample.id.rfind(kImportIdPrefix, 0) == 0); + CHECK(e.sample.id != "cap-1-kick.wav"); + CHECK(e.sample.id != "cap-2-snare.wav"); + } + CHECK(plan.entries[0].sample.id != plan.entries[1].sample.id); +} + +static void testReimportingIntoTheSourceProjectRemintsRatherThanCollides() { + // The package came FROM this project, so its ids are the ones already in use. + BankBook book = bookWithBanks({"B"}); + Sample existing; + existing.id = "cap-1-kick.wav"; + existing.relativePath = "reasampler_bank/kick.wav"; + existing.contentHash = "h1"; + book.index("bank-0")->add(existing); + + const PackageManifest m = manifestOf({entry("kick.wav", "cap-1-kick.wav", "h1")}, "B"); + const ImportPlan plan = + planImport(m, book, kProjectDir, {"kick.wav"}, kTag); + + CHECK(plan.bankDisplayName == "B 2"); + CHECK(landed(plan, 0).sample.id != "cap-1-kick.wav"); + // The hash lives in another bank; cross-bank dedup is deliberately not enforced, + // so the entry still lands rather than collapsing onto B's copy. + CHECK(landed(plan, 0).action == EntryAction::Land); + CHECK(landed(plan, 0).renamed); +} + +static void testParentIsRemappedWhenItTravelledInThePackage() { + PackageEntry parent = entry("kick.wav", "cap-parent", "h1"); + PackageEntry child = entry("kick_r2.wav", "cap-child", "h2"); + child.sample.provenance = model::Provenance{"cap-parent", "fx-snapshot"}; + + const ImportPlan plan = planImport(manifestOf({parent, child}, "Drums"), + bookWithBanks({}), kProjectDir, {}, kTag); + + CHECK(landed(plan, 1).sample.provenance.has_value()); + CHECK(landed(plan, 1).sample.provenance->parentSampleId == + landed(plan, 0).sample.id); + CHECK(landed(plan, 1).sample.provenance->fxChainSnapshot == "fx-snapshot"); +} + +static void testParentIsRemappedEvenWhenItFollowsTheChild() { + // Manifest order does not constrain lineage, so the remap runs after every id is minted. + PackageEntry child = entry("kick_r2.wav", "cap-child", "h2"); + child.sample.provenance = model::Provenance{"cap-parent", ""}; + PackageEntry parent = entry("kick.wav", "cap-parent", "h1"); + + const ImportPlan plan = planImport(manifestOf({child, parent}, "Drums"), + bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(landed(plan, 0).sample.provenance->parentSampleId == landed(plan, 1).sample.id); +} + +static void testForeignParentIsClearedNotCarried() { + PackageEntry child = entry("kick.wav", "cap-child", "h1"); + child.sample.provenance = model::Provenance{"cap-not-in-this-package", "fx"}; + + const ImportPlan plan = planImport(manifestOf({child}, "Drums"), + bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(landed(plan, 0).sample.provenance.has_value()); + CHECK(landed(plan, 0).sample.provenance->parentSampleId.empty()); + CHECK(landed(plan, 0).sample.provenance->fxChainSnapshot == "fx"); +} + +// --- bank-folder file names (collision class 2) ------------------------------ + +static void testAFreeBankLegalNameIsKept() { + const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"), + bookWithBanks({}), kProjectDir, + {"unrelated.wav"}, kTag); + CHECK(landed(plan, 0).destFileName == "kick.wav"); + CHECK(!landed(plan, 0).renamed); + CHECK(plan.collisionRenameCount == 0); + CHECK(plan.sanitizeRenameCount == 0); + CHECK(landed(plan, 0).sample.relativePath == "reasampler_bank/kick.wav"); +} + +static void testATakenNameIsMintedFreshAndNeverOverwritten() { + const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"), + bookWithBanks({}), kProjectDir, + {"kick.wav"}, kTag); + CHECK(landed(plan, 0).destFileName != "kick.wav"); + CHECK(landed(plan, 0).renamed); + // A genuine folder-name collision, not a spelling mint. + CHECK(plan.collisionRenameCount == 1); + CHECK(plan.sanitizeRenameCount == 0); + CHECK(landed(plan, 0).sample.relativePath == + "reasampler_bank/" + landed(plan, 0).destFileName); +} + +static void testTheFolderNameCheckFoldsAsciiCase() { + // Windows and the default APFS would land "kick.wav" onto "KICK.WAV". + const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"), + bookWithBanks({}), kProjectDir, + {"KICK.WAV"}, kTag); + CHECK(landed(plan, 0).destFileName != "kick.wav"); + CHECK(landed(plan, 0).renamed); + // The case-fold hit is still a collision, not a spelling mint. + CHECK(plan.collisionRenameCount == 1); + CHECK(plan.sanitizeRenameCount == 0); +} + +static void testTwoEntriesNeverLandOnOneName() { + // Two package names that differ only by case are one destination file. + const ImportPlan plan = + planImport(manifestOf({entry("kick.wav", "a", "h1"), entry("Kick.wav", "b", "h2")}, + "Drums"), + bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(plan.landCount == 2); + CHECK(landed(plan, 0).destFileName != landed(plan, 1).destFileName); +} + +static void testAnUnsanitaryNameIsMintedRatherThanLandedVerbatim() { + const ImportPlan plan = + planImport(manifestOf({entry("Hit One.wav", "a", "h1")}, "Drums"), + bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(landed(plan, 0).destFileName.find(' ') == std::string::npos); + CHECK(landed(plan, 0).renamed); + // No collision here (the bank folder is empty) — this is a sanitize mint. + CHECK(plan.sanitizeRenameCount == 1); + CHECK(plan.collisionRenameCount == 0); +} + +// --- content hash (collision class 3) ---------------------------------------- + +static void testAnAlreadyLandedHashCollapsesWithoutAWrite() { + const ImportPlan plan = + planImport(manifestOf({entry("kick.wav", "a", "same"), + entry("kick_copy.wav", "b", "same")}, + "Drums"), + bookWithBanks({}), kProjectDir, {}, kTag); + + CHECK(plan.landCount == 1); + CHECK(plan.collapseCount == 1); + CHECK(landed(plan, 0).action == EntryAction::Land); + CHECK(landed(plan, 1).action == EntryAction::Collapse); + // No name is claimed for it — a dedup that wrote a file would manufacture an orphan. + CHECK(landed(plan, 1).destFileName.empty()); + // Every manifest entry still yields exactly one planned entry: planImport is total. + CHECK(plan.entries.size() == 2); +} + +static void testAParentPointingAtACollapsedEntryResolvesToTheSurvivor() { + PackageEntry first = entry("kick.wav", "cap-first", "same"); + PackageEntry dupe = entry("kick_copy.wav", "cap-dupe", "same"); + PackageEntry child = entry("kick_r2.wav", "cap-child", "other"); + child.sample.provenance = model::Provenance{"cap-dupe", ""}; + + const ImportPlan plan = planImport(manifestOf({first, dupe, child}, "Drums"), + bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(landed(plan, 2).sample.provenance->parentSampleId == landed(plan, 0).sample.id); +} + +static void testAnEmptyHashNeverCollapses() { + // Mirrors findByHash: an unhashable entry does not participate in dedup. + const ImportPlan plan = + planImport(manifestOf({entry("a.wav", "a", ""), entry("b.wav", "b", "")}, "Drums"), + bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(plan.landCount == 2); + CHECK(plan.collapseCount == 0); +} + +// --- slots ------------------------------------------------------------------- + +static void testSlotsRideAlongOverTheRemintedIds() { + PackageManifest m = manifestOf({entry("kick.wav", "cap-a", "h1"), + entry("snare.wav", "cap-b", "h2")}, + "Drums"); + // A gap the package carried: slot 0 empty, occupants at 1 and 3. + m.slots = SlotMap::fromEntries({{"cap-a", 1}, {"cap-b", 3}}); + + const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(plan.slots.slotOf(landed(plan, 0).sample.id) == 1); + CHECK(plan.slots.slotOf(landed(plan, 1).sample.id) == 3); + // The package's own ids are gone from the map — a foreign id never enters the index. + CHECK(plan.slots.slotOf("cap-a") == -1); +} + +static void testACollapsedEntryDoesNotDoubleOccupyASlot() { + PackageManifest m = manifestOf({entry("kick.wav", "cap-a", "same"), + entry("kick_copy.wav", "cap-b", "same")}, + "Drums"); + m.slots = SlotMap::fromEntries({{"cap-a", 0}, {"cap-b", 1}}); + + const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag); + CHECK(plan.slots.size() == 1); + CHECK(plan.slots.slotOf(landed(plan, 0).sample.id) == 0); +} + +// --- the ledger gate --------------------------------------------------------- + +static void testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot() { + CHECK(importLedgerRefusal(tracking::LedgerStatus::Unreadable) == + LedgerRefusal::Malformed); + CHECK(importLedgerRefusal(tracking::LedgerStatus::FutureVersion) == + LedgerRefusal::FutureVersion); + CHECK(importLedgerRefusal(tracking::LedgerStatus::Fresh) == LedgerRefusal::None); + CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None); +} + +static void testAnUndecodableUsageKeyBlocksPruneButNotImport() { + // The tempting reuse of PruneReport::blockedByTracking would silently refuse an + // import over a key that only ever governs what a DELETION may touch. + tracking::OriginLedger ledger; + wire::UsageFoldResult usage; + usage.abortPrune = true; + usage.offendingKeys = {"rsusage_{ABC}"}; + + const tracking::TrackingState state{tracking::LedgerStatus::Loaded, ledger, usage}; + CHECK(tracking::pruneProtection(state).blocked); + CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None); +} + +// Pins the delegation itself: importLedgerRefusal must refuse EXACTLY the statuses +// ledgerDegraded() calls degraded, over every value the enum has today. A gate that +// re-derived its own notion of "degraded" could silently diverge from this the moment +// either side changes without the other. +static void testImportLedgerRefusalDelegatesToLedgerDegraded() { + const tracking::LedgerStatus all[] = { + tracking::LedgerStatus::Fresh, + tracking::LedgerStatus::Loaded, + tracking::LedgerStatus::Unreadable, + tracking::LedgerStatus::FutureVersion, + }; + for (tracking::LedgerStatus s : all) + CHECK((importLedgerRefusal(s) != LedgerRefusal::None) == tracking::ledgerDegraded(s)); +} + +// --- the ledger-refusal message (pure, so both channels are assertable without a DAW) -- + +static void testLedgerRefusalMessageIsEmptyForNone() { + CHECK(ledgerRefusalMessage(LedgerRefusal::None, "reasampler").empty()); +} + +static void testLedgerRefusalMessageNamesTheChannelCorrectNamespace() { + // The two real namespaces (app_version.h): stable "reasampler", beta "reasampler_beta". + const std::string stable = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler"); + CHECK(stable.find("\"reasampler\"") != std::string::npos); + CHECK(stable.find("reasampler_beta") == std::string::npos); + + const std::string beta = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler_beta"); + CHECK(beta.find("\"reasampler_beta\"") != std::string::npos); +} + +static void testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion() { + const std::string malformed = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler"); + const std::string futureVersion = + ledgerRefusalMessage(LedgerRefusal::FutureVersion, "reasampler"); + CHECK(malformed != futureVersion); + CHECK(malformed.find("malformed") != std::string::npos); + CHECK(futureVersion.find("NEWER version") != std::string::npos); +} + +int main() { + testFreeSeedIsKeptVerbatim(); + testFoldedCollisionTakesTheFirstSuffix(); + testProbeFillsAGap(); + testSeedIsNeverReparsed(); + testBlankRecordedNameFallsBackToTheDefault(); + testPoolExportLandsAsANamedBank(); + testRepeatedImportsWalkTheSuffixUpwards(); + + testEveryIdIsRemintedUnderTheImportPrefix(); + testReimportingIntoTheSourceProjectRemintsRatherThanCollides(); + testParentIsRemappedWhenItTravelledInThePackage(); + testParentIsRemappedEvenWhenItFollowsTheChild(); + testForeignParentIsClearedNotCarried(); + + testAFreeBankLegalNameIsKept(); + testATakenNameIsMintedFreshAndNeverOverwritten(); + testTheFolderNameCheckFoldsAsciiCase(); + testTwoEntriesNeverLandOnOneName(); + testAnUnsanitaryNameIsMintedRatherThanLandedVerbatim(); + + testAnAlreadyLandedHashCollapsesWithoutAWrite(); + testAParentPointingAtACollapsedEntryResolvesToTheSurvivor(); + testAnEmptyHashNeverCollapses(); + + testSlotsRideAlongOverTheRemintedIds(); + testACollapsedEntryDoesNotDoubleOccupyASlot(); + + testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot(); + testAnUndecodableUsageKeyBlocksPruneButNotImport(); + testImportLedgerRefusalDelegatesToLedgerDegraded(); + + testLedgerRefusalMessageIsEmptyForNone(); + testLedgerRefusalMessageNamesTheChannelCorrectNamespace(); + testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion(); + + if (g_fail == 0) std::printf("import_plan: all tests passed\n"); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_origin_ledger.cpp b/tests/test_origin_ledger.cpp index ff2fb8f..aa1eecd 100644 --- a/tests/test_origin_ledger.cpp +++ b/tests/test_origin_ledger.cpp @@ -1,10 +1,9 @@ // Standalone tests for reasampler::tracking::OriginLedger — no REAPER, no framework. // -// The record family behind file tracking. Covers: the relative-paths-only invariant, -// exact-string ownership, dedup, insertion order, the JSON round-trip (incl. golden -// byte literals over every persisted enum value), the no-backfill rule, the legacy -// path-only lift, and the Fresh / Loaded / Unreadable / FutureVersion classification -// that keeps never-recorded apart from the two degraded states. +// Covers: relative-paths-only, exact-string ownership, dedup, insertion order, the +// JSON round-trip (incl. golden bytes over every persisted enum value), the append-a- +// kind rules, the no-backfill rule, the legacy path-only lift, and the Fresh / Loaded +// / Unreadable / FutureVersion classification. #include "../src/core/tracking/origin_ledger.h" @@ -162,13 +161,15 @@ static void testSerializeGoldenLiteralPinsEveryPersistedKind() { l.record(rec("bank/ingest.wav", OriginKind::Ingest, "S-b")); l.record(rec("bank/recapture.wav", OriginKind::Recapture, "S-c", "S-a")); l.record(rec("bank/resample.wav", OriginKind::Resample, "S-d", "S-a")); + l.record(rec("bank/import.wav", OriginKind::PackageImport, "S-e")); const std::string expected = "{\"v\":2,\"records\":[" "{\"path\":\"bank/unknown.wav\",\"kind\":0,\"sample\":\"\",\"parent\":\"\"}," "{\"path\":\"bank/capture.wav\",\"kind\":1,\"sample\":\"S-a\",\"parent\":\"\"}," "{\"path\":\"bank/ingest.wav\",\"kind\":2,\"sample\":\"S-b\",\"parent\":\"\"}," "{\"path\":\"bank/recapture.wav\",\"kind\":3,\"sample\":\"S-c\",\"parent\":\"S-a\"}," - "{\"path\":\"bank/resample.wav\",\"kind\":4,\"sample\":\"S-d\",\"parent\":\"S-a\"}" + "{\"path\":\"bank/resample.wav\",\"kind\":4,\"sample\":\"S-d\",\"parent\":\"S-a\"}," + "{\"path\":\"bank/import.wav\",\"kind\":5,\"sample\":\"S-e\",\"parent\":\"\"}" "]}"; CHECK(l.serialize() == expected); @@ -180,6 +181,71 @@ static void testSerializeGoldenLiteralPinsEveryPersistedKind() { CHECK(back->find("bank/ingest.wav")->kind == OriginKind::Ingest); CHECK(back->find("bank/recapture.wav")->kind == OriginKind::Recapture); CHECK(back->find("bank/resample.wav")->kind == OriginKind::Resample); + CHECK(back->find("bank/import.wav")->kind == OriginKind::PackageImport); + CHECK(*back == l); // every field, not just the kind, survives the trip +} + +// Pins the emitted "v" byte, not the internal constant — an older build reads bytes. +static void testAppendingAKindDoesNotMoveTheDocumentVersion() { + OriginLedger l; + l.record(rec("bank/import.wav", OriginKind::PackageImport, "S-e")); + const std::string json = l.serialize(); + CHECK(json.rfind("{\"v\":2,", 0) == 0); + CHECK(loadLedger(json).status == LedgerStatus::Loaded); + + // The ceiling did not move with it: v3 is still a future document shape. + CHECK(loadLedger("{\"v\":3,\"records\":[]}").status == LedgerStatus::FutureVersion); +} + +// Pins three unrecognized kind values (future, far-future, negative) all landing on +// Unknown with the ledger still Loaded and the path still owned. +static void testUnknownKindDegradesWithoutBlockingTheLedger() { + const std::string blob = + "{\"v\":2,\"records\":[" + "{\"path\":\"bank/next.wav\",\"kind\":6,\"sample\":\"S-1\",\"parent\":\"\"}," + "{\"path\":\"bank/far.wav\",\"kind\":99,\"sample\":\"S-2\",\"parent\":\"\"}," + "{\"path\":\"bank/bogus.wav\",\"kind\":-1,\"sample\":\"S-3\",\"parent\":\"\"}]}"; + + const LedgerLoad load = loadLedger(blob); + CHECK(load.status == LedgerStatus::Loaded); + CHECK(!ledgerDegraded(load.status)); + CHECK(load.ledger.size() == 3); + CHECK(load.ledger.find("bank/next.wav")->kind == OriginKind::Unknown); + CHECK(load.ledger.find("bank/far.wav")->kind == OriginKind::Unknown); + CHECK(load.ledger.find("bank/bogus.wav")->kind == OriginKind::Unknown); + + // Every path still owned, in order — the protection prune reads is untouched. + const std::vector paths = load.ledger.ownedPaths(); + CHECK(paths.size() == 3); + CHECK(paths[0] == "bank/next.wav"); + CHECK(paths[1] == "bank/far.wav"); + CHECK(paths[2] == "bank/bogus.wav"); + + // The rest of the record survives the degrade; only the kind is lost. + CHECK(load.ledger.find("bank/next.wav")->sampleId == "S-1"); +} + +// ownedPaths() is the ONLY thing pruneProtection reads out of a ledger, and it is +// kind-blind: two ledgers agreeing on paths and differing on every kind, new value +// included, yield identical protection input. Adding a kind therefore cannot change +// a prune decision for any existing kind. +static void testOwnedPathsAreKindIndependent() { + const OriginKind kinds[] = {OriginKind::Unknown, OriginKind::Capture, + OriginKind::Ingest, OriginKind::Recapture, + OriginKind::Resample, OriginKind::PackageImport}; + const std::size_t n = sizeof(kinds) / sizeof(kinds[0]); + + OriginLedger baseline; + OriginLedger varied; + for (std::size_t i = 0; i < n; ++i) { + const std::string path = "bank/f" + std::to_string(i) + ".wav"; + baseline.record(rec(path, OriginKind::Unknown, "S")); + varied.record(rec(path, kinds[i], "S")); + } + + CHECK(baseline.ownedPaths() == varied.ownedPaths()); + CHECK(baseline.ownedPaths().size() == n); + CHECK(!(baseline == varied)); // the ledgers really do differ, kind by kind } // contains() is an EXACT-string predicate, never a prefix or substring match — the @@ -383,6 +449,9 @@ int main() { testRoundTripWithLineage(); testRoundTripWithJsonMetacharacters(); testSerializeGoldenLiteralPinsEveryPersistedKind(); + testAppendingAKindDoesNotMoveTheDocumentVersion(); + testUnknownKindDegradesWithoutBlockingTheLedger(); + testOwnedPathsAreKindIndependent(); testMalformedParsesToNullopt(); testTrailingGarbageIsRejected(); testLegacyShapeTypeErrorsAreRejectedButEmptyIsValid(); diff --git a/tests/test_package_compat.cpp b/tests/test_package_compat.cpp new file mode 100644 index 0000000..38cd5b7 --- /dev/null +++ b/tests/test_package_compat.cpp @@ -0,0 +1,402 @@ +// Standalone tests over the FROZEN package-compat corpus — no REAPER, no framework. +// Nothing here builds a package: every byte comes off disk exactly as committed, which +// is the only shape in which a later format change can be caught breaking a +// compatibility direction. A test that re-derived its own fixture would prove only that +// the codec agrees with itself. See tests/fixtures/package_compat/README.md. +// +// The values pinned below are the FIXTURE's facts, not this build's — the writer semver +// especially must never be compared against version::stampVersion(), or a version bump +// would silently re-anchor the corpus. + +#include "../src/core/package/bank_package.h" + +#include +#include +#include +#include +#include +#include + +#include "../src/core/package/package_format.h" +#include "../src/core/package/package_manifest.h" +#include "package_fixtures.h" + +using namespace reasampler::package; +using namespace reasampler::model; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- what the v1 fixture's bytes say ----------------------------------------- + +static constexpr const char* kV1File = "v1_shipping.rsbank"; +static constexpr const char* kV1WriterSemver = "1.4.0"; +static constexpr const char* kV1EntryName = "kick.wav"; +static constexpr const char* kV1EntryHash = "8df36e805531e4af"; +static constexpr std::uint64_t kV1PrefixSize = 907; +static constexpr std::uint64_t kV1PayloadLength = 300; +static constexpr std::uint64_t kV1TotalSize = 1207; + +// Every Sample field the shipping build wrote, with every optional PRESENT — so +// "every known field intact" is a claim about the whole record, not a sampled few. +// relativePath is the BARE transport name: export_plan normalizes it (see +// src/core/package/CLAUDE.md), so a package carrying a directory component here would +// mean the fixture predates that rule. +static Sample expectedV1Sample() { + Sample s; + s.id = "cap-kick"; + s.displayName = "Kick (wet)"; + s.relativePath = "kick.wav"; + s.sourceMode = SourceMode::RazorArea; + s.sourceRange = {1.25, 3.5, 480.0, 1920.0}; + s.trackGuids = {"{AAA}", "{BBB}"}; + s.wetDry = 0.75; + s.channelCount = 1; + s.sampleRate = 48000; + s.lengthSeconds = 0.0026666666666666666; + s.lengthBeats = 0.00533; + s.captureTempo = 120.5; + s.captureTimeSigNum = 7; + s.captureTimeSigDenom = 8; + s.key = "F#m"; + s.rootNote = 60; + s.loop = LoopPoints{8, 120}; + s.levels = {-0.3, -12.7, -14.0}; + s.clipped = true; + s.tier = Tier::Archive; + s.contentHash = "Wcompatcorpus0001"; + s.provenance = Provenance{"cap-parent", "fx-snapshot"}; + s.createdTimestamp = 1754000000; + return s; +} + +static PackageManifest expectedV1Manifest() { + PackageManifest m; + m.bankDisplayName = "Compat Corpus"; + m.exportTimestamp = 1754100000; + m.entries.push_back({kV1EntryName, kV1PayloadLength, kV1EntryHash, expectedV1Sample()}); + m.slots.append("cap-kick"); + return m; +} + +// A fixture that failed to open reads as an empty buffer, and an empty buffer decodes +// Malformed — which would let half this file pass vacuously. Every suite loads through +// here. A size mismatch is fatal to the caller (false), not just recorded: proceeding +// with a short or empty buffer would let classifyFixture/le32At index out of range and +// the TooNew test's hand-lifted manifest slice overread the heap, rather than fail clean. +static bool load(const char* name, std::size_t expectedSize, std::vector& out) { + out = packageFixtureBytes(name); + if (out.size() != expectedSize) { + std::printf("FAIL: fixture %s is %zu bytes, expected %zu (path: %s)\n", name, + out.size(), expectedSize, packageFixturePath(name).c_str()); + ++g_fail; + return false; + } + return true; +} + +static bool containsToken(const std::vector& bytes, const std::string& token) { + const std::string text(reinterpret_cast(bytes.data()), bytes.size()); + return text.find(token) != std::string::npos; +} + +static std::uint32_t le32At(const std::vector& bytes, std::size_t at) { + return static_cast(bytes[at]) | + (static_cast(bytes[at + 1]) << 8) | + (static_cast(bytes[at + 2]) << 16) | + (static_cast(bytes[at + 3]) << 24); +} + +// --- direction 1: the shipping build reads what it wrote --------------------- + +// The header pair as the bytes carry it, classified without going through decode — the +// ladder rule stated against the file rather than against the decoder's reading of it. +static PackageReadability classifyFixture(const std::vector& bytes) { + return classifyPackageVersion(le32At(bytes, 4), le32At(bytes, 8)); +} + +static void testV1FixtureDecodesToTheRecordTheShippingBuildWrote() { + std::vector bytes; + if (!load(kV1File, kV1TotalSize, bytes)) return; + CHECK(classifyFixture(bytes) == PackageReadability::Readable); + + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::Readable); + CHECK(dec.header.formatVersion == 1); + CHECK(dec.header.minReaderVersion == 1); + CHECK(dec.header.writerVersion == kV1WriterSemver); + + CHECK(dec.manifest.entries.size() == 1); + CHECK(dec.manifest.bankDisplayName == "Compat Corpus"); + if (dec.manifest.entries.size() == 1) { + CHECK(dec.manifest.entries[0].fileName == kV1EntryName); + CHECK(dec.manifest.entries[0].byteLength == kV1PayloadLength); + CHECK(dec.manifest.entries[0].byteHash == kV1EntryHash); + CHECK(dec.manifest.entries[0].sample == expectedV1Sample()); + } + CHECK(dec.manifest == expectedV1Manifest()); + + CHECK(dec.prefixSize == kV1PrefixSize); + CHECK(dec.layout.size() == 1); + if (dec.layout.size() == 1) { + CHECK(dec.layout[0].name == kV1EntryName); + CHECK(dec.layout[0].offset == kV1PrefixSize); + CHECK(dec.layout[0].length == kV1PayloadLength); + } +} + +// --- direction 1: a NEWER additive writer still reads ------------------------ + +// formatVersion N+1, minReaderVersion unchanged: the whole reason two integers exist. +// The fixture carries three keys this build has never heard of — one at the manifest +// root, one on the entry, one inside the nested Sample blob — and must still decode to +// exactly what the v1 fixture decodes to. +static void testAdditiveForwardFixtureReadsWithEveryKnownFieldIntact() { + std::vector bytes; + if (!load("additive_forward.rsbank", 1283, bytes)) return; + + // Non-vacuity: the unknown keys are genuinely in the bytes, so the equality below + // is "skipped without error", not "there was nothing to skip". + CHECK(containsToken(bytes, "\"exportTool\"")); + CHECK(containsToken(bytes, "\"futureEntryKey\"")); + CHECK(containsToken(bytes, "\"futureSampleKey\"")); + + CHECK(classifyFixture(bytes) == PackageReadability::Readable); + + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::Readable); + // Literal, not kPackageFormatVersion-relative: these are the FIXTURE's frozen + // version pair (2/1), not this build's — see the file header note. + CHECK(dec.header.formatVersion == 2); + CHECK(dec.header.minReaderVersion == 1); + CHECK(dec.header.writerVersion == "1.9.0"); + + // Every known field, end to end: same manifest the v1 fixture yields. + CHECK(dec.manifest == expectedV1Manifest()); + CHECK(dec.layout.size() == 1); + if (dec.layout.size() == 1) CHECK(dec.layout[0].length == kV1PayloadLength); +} + +// --- direction 2: a structural newer writer is refused whole ----------------- + +static void testRefuseFixtureIsTooNewAndStillNamesTheWriter() { + std::vector bytes; + if (!load("refuse_structural.rsbank", kV1TotalSize, bytes)) return; + CHECK(classifyFixture(bytes) == PackageReadability::TooNew); + + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::TooNew); + // The three facts the refusal message owes the user. Literal, not + // kPackageFormatVersion-relative — this is the FIXTURE's frozen pair (2/2). + CHECK(dec.header.formatVersion == 2); + CHECK(dec.header.minReaderVersion == 2); + CHECK(dec.header.writerVersion == "1.9.0"); + + // Nothing else: no manifest, no layout, no partial success. + CHECK(dec.manifest == PackageManifest{}); + CHECK(dec.layout.empty()); + CHECK(dec.prefixSize == 0); + + // The refusal is a DECISION, not an inability: this fixture's body is the v1 + // fixture's own manifest, which parses. Walk the frozen header by hand to lift it + // out — a reader at this version is forbidden from doing so, which is the point. + const std::size_t manifestLenAt = 16 + le32At(bytes, 12); + const std::uint32_t manifestLen = le32At(bytes, manifestLenAt); + const std::string body(reinterpret_cast(bytes.data() + manifestLenAt + 4), + manifestLen); + CHECK(deserializeManifest(body).has_value()); +} + +// --- truncation: Malformed at every site, never TooNew ----------------------- + +// One fixture per DISTINCT decode failure site rather than an arithmetic spread. The +// RSBK layout is derived from the manifest's entries, not stored as its own section, so +// the site a "mid-layout" cut maps to is the payload boundary: the manifest parses, the +// layout computes, and the exact-size proof is what fails. +struct Truncation { + const char* file; + std::size_t size; // the cut offset — the committed file IS this many bytes + const char* site; +}; + +static const Truncation kTruncations[] = { + {"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 — 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"}, +}; + +static void testEveryTruncationIsMalformedNeverTooNew() { + for (const Truncation& t : kTruncations) { + std::vector bytes; + if (!load(t.file, t.size, bytes)) continue; + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + if (dec.status != PackageReadability::Malformed) { + std::printf("FAIL: %s (%s) classified %s, expected Malformed\n", t.file, t.site, + dec.status == PackageReadability::TooNew ? "TooNew" : "Readable"); + ++g_fail; + } + // A refusal never half-succeeds, at any site. + CHECK(dec.manifest.entries.empty()); + CHECK(dec.layout.empty()); + CHECK(dec.prefixSize == 0); + + // The shell hands decode a PREFIX plus the observed file size, not the whole + // file — so the verdict has to survive that call shape too, at every cut the + // incremental seam can actually satisfy. + const auto need = requiredPrefixSize(bytes); + if (need && *need <= bytes.size()) { + const std::vector head(bytes.begin(), + bytes.begin() + static_cast(*need)); + CHECK(decodePackage(head, bytes.size()).status == PackageReadability::Malformed); + } + } +} + +// --- truncation: the additive-forward ladder direction, not just v1 --------- + +// Every truncation above is a prefix of v1_shipping.rsbank (formatVersion == ours), so +// none of them ever puts formatVersion > kPackageFormatVersion on the exact-size-proof +// failure path — the one relabeling branch (src/core/package/CLAUDE.md: "the parse +// branch is the ONLY one that relabels") never gets exercised from the TooNew side. +// This fixture is additive_forward.rsbank (formatVersion 2, one past ours) cut exactly +// at its manifest/payload boundary: the manifest parses whole, so the failure is the +// exact-size proof, not a parse failure — it must stay Malformed, not relabel to TooNew. +static void testAdditiveForwardTruncatedAtPayloadBoundaryStaysMalformed() { + std::vector bytes; + if (!load("trunc_additive_forward.rsbank", 983, bytes)) return; + CHECK(classifyFixture(bytes) == PackageReadability::Readable); + + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + CHECK(dec.status == PackageReadability::Malformed); + CHECK(dec.manifest.entries.empty()); + CHECK(dec.layout.empty()); + CHECK(dec.prefixSize == 0); +} + +// --- hostile names: refused at decode, before any planner exists ------------- + +// The two fields carry DIFFERENT rules (src/core/package/CLAUDE.md): the entry name may +// not express a path at all, while the nested relativePath is a path and is refused only +// for traversal and absolute forms. Each fixture keeps the other field clean, so the +// refusal is attributable to the field under test. +struct HostileFixture { + const char* file; + std::size_t size; + const char* offending; // the form as it reads once JSON-unescaped + bool inEntryName; // false: the nested Sample's relativePath +}; + +static const HostileFixture kHostiles[] = { + {"hostile_name_dotdot.rsbank", 624, "..", true}, + {"hostile_name_parent_slash.rsbank", 633, "../evil.wav", true}, + {"hostile_name_parent_backslash.rsbank", 634, "..\\evil.wav", true}, + {"hostile_name_subdir_slash.rsbank", 634, "sub/evil.wav", true}, + {"hostile_name_drive_absolute.rsbank", 643, "C:\\Windows\\evil.wav", true}, + {"hostile_name_unc_absolute.rsbank", 646, "\\\\srv\\share\\evil.wav", true}, + {"hostile_path_dotdot_slash.rsbank", 641, "bank/../../evil.wav", false}, + {"hostile_path_dotdot_backslash.rsbank", 640, "bank\\..\\evil.wav", false}, + {"hostile_path_rooted.rsbank", 635, "/etc/evil.wav", false}, + {"hostile_path_drive_absolute.rsbank", 643, "C:\\Windows\\evil.wav", false}, + {"hostile_path_unc_absolute.rsbank", 646, "\\\\srv\\share\\evil.wav", false}, +}; + +// A backslash rides the manifest JSON doubled; the corpus table above spells the +// unescaped form, since that is what the naming rules are asked about. +static std::string jsonEscaped(const std::string& s) { + std::string out; + for (char c : s) { + if (c == '\\') out += '\\'; + out += c; + } + return out; +} + +static void testEveryHostileNameIsRefusedAtDecode() { + // The clean spelling both fields hold in the fixture they are NOT under test in — + // without this, a refusal could be coming from the wrong field. + CHECK(isValidEntryName("kick.wav")); + CHECK(isValidNestedSamplePath("kick.wav")); + + for (const HostileFixture& h : kHostiles) { + std::vector bytes; + if (!load(h.file, h.size, bytes)) continue; + if (!containsToken(bytes, jsonEscaped(h.offending))) { + std::printf("FAIL: %s does not carry the form it is named for (%s)\n", h.file, + h.offending); + ++g_fail; + } + + // The format's own rule on the offending field, stated directly. + if (h.inEntryName) CHECK(!isValidEntryName(h.offending)); + else CHECK(!isValidNestedSamplePath(h.offending)); + + // Refused by the CODEC: decode yields no manifest at all, so there is nothing + // for a planner to have been handed. planImport takes a PackageManifest, and + // decode produced none. + const DecodedPackage dec = decodePackage(bytes, bytes.size()); + if (dec.status != PackageReadability::Malformed) { + std::printf("FAIL: %s classified %d, expected Malformed\n", h.file, + static_cast(dec.status)); + ++g_fail; + } + CHECK(dec.manifest == PackageManifest{}); + CHECK(dec.layout.empty()); + CHECK(dec.prefixSize == 0); + } +} + +// --- inventory: every fixture on disk is exercised by SOME test above -------- + +// This TU's own tables (the three named fixtures plus kTruncations and kHostiles) are +// the fullest account of the corpus in the tree — every other consumer (the round-trip +// harness, the README) tests a subset of these same files. Enumerating the fixture +// directory here and failing on anything absent from this list is the one check that +// catches a fixture added to disk but never wired into a table: a silent coverage drop +// that would otherwise leave a green suite. +static void testEveryFixtureOnDiskIsInSomeTable() { + std::vector known = {kV1File, "additive_forward.rsbank", + "refuse_structural.rsbank", + "trunc_additive_forward.rsbank"}; + for (const Truncation& t : kTruncations) known.push_back(t.file); + for (const HostileFixture& h : kHostiles) known.push_back(h.file); + + std::error_code ec; + for (const auto& entry : + std::filesystem::directory_iterator(REASAMPLER_PACKAGE_FIXTURE_DIR, ec)) { + if (entry.path().extension() != ".rsbank") continue; + const std::string name = entry.path().filename().string(); + if (std::find(known.begin(), known.end(), name) == known.end()) { + std::printf("FAIL: fixture %s exists on disk but is in no table in this file\n", + name.c_str()); + ++g_fail; + } + } + if (ec) { + std::printf("FAIL: could not list fixture directory: %s\n", ec.message().c_str()); + ++g_fail; + } +} + +int main() { + testV1FixtureDecodesToTheRecordTheShippingBuildWrote(); + testAdditiveForwardFixtureReadsWithEveryKnownFieldIntact(); + testRefuseFixtureIsTooNewAndStillNamesTheWriter(); + testEveryTruncationIsMalformedNeverTooNew(); + testAdditiveForwardTruncatedAtPayloadBoundaryStaysMalformed(); + testEveryHostileNameIsRefusedAtDecode(); + testEveryFixtureOnDiskIsInSomeTable(); + + if (g_fail == 0) { + std::printf("package_compat_tests: all passed\n"); + return 0; + } + std::printf("package_compat_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_package_format.cpp b/tests/test_package_format.cpp new file mode 100644 index 0000000..adf79d6 --- /dev/null +++ b/tests/test_package_format.cpp @@ -0,0 +1,240 @@ +// Standalone tests for reasampler::package's format contract — no REAPER, no +// test framework. Pins the version-ladder classification (both integers, every +// branch) and the three naming rules: the entry-name rule, the ASCII-folding +// name equivalence, and the nested-path traversal guard. + +#include "../src/core/package/package_format.h" + +#include +#include + +using namespace reasampler::package; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- classifyPackageVersion -------------------------------------------------- + +static void testClassifyReadable() { + CHECK(classifyPackageVersion(kPackageFormatVersion, kPackageMinReaderVersion) == + PackageReadability::Readable); + // The additive-forward-compat direction: a newer writer whose minReader + // still reaches back to this build reads fine. + CHECK(classifyPackageVersion(kPackageFormatVersion + 5, kPackageMinReaderVersion) == + PackageReadability::Readable); + // Boundary: minReader exactly this build's format version. + CHECK(classifyPackageVersion(kPackageFormatVersion + 1, kPackageFormatVersion) == + PackageReadability::Readable); +} + +static void testClassifyTooNew() { + // Boundary: one past this build's format version refuses. + CHECK(classifyPackageVersion(kPackageFormatVersion + 1, kPackageFormatVersion + 1) == + PackageReadability::TooNew); + CHECK(classifyPackageVersion(99, 42) == PackageReadability::TooNew); +} + +static void testClassifyMalformed() { + // Zero versions: no honest writer emits them (the ladder starts at 1). + CHECK(classifyPackageVersion(0, 0) == PackageReadability::Malformed); + CHECK(classifyPackageVersion(1, 0) == PackageReadability::Malformed); + CHECK(classifyPackageVersion(0, 1) == PackageReadability::Malformed); + // A writer cannot require a reader newer than what it wrote. + CHECK(classifyPackageVersion(1, 2) == PackageReadability::Malformed); + // Incoherence outranks TooNew: even with both above this build, minReader > + // formatVersion is Malformed, not a refusal message. + CHECK(classifyPackageVersion(5, 9) == PackageReadability::Malformed); +} + +// --- isValidEntryName -------------------------------------------------------- + +static void testEntryNameAccepts() { + CHECK(isValidEntryName("kick.wav")); + CHECK(isValidEntryName("Snare 03 (wet).wav")); + CHECK(isValidEntryName("no-extension")); + CHECK(isValidEntryName(".hidden")); // a leading dot is a bare name + CHECK(isValidEntryName("a.b.c.wav")); // single dots are fine + CHECK(isValidEntryName(std::string(kMaxEntryNameBytes, 'x'))); // at the cap + // Legal names containing a ".." substring that is not the whole name: a + // name can only ever be one path component (separators are banned), so + // ".." as a component is the only expressible traversal. + CHECK(isValidEntryName("take..final.wav")); + CHECK(isValidEntryName("loop...wav")); + CHECK(isValidEntryName("a..b")); +} + +static void testEntryNameRejectsSeparatorsAndDots() { + CHECK(!isValidEntryName("")); + CHECK(!isValidEntryName(".")); + CHECK(!isValidEntryName("..")); + CHECK(!isValidEntryName("..\\evil.wav")); + CHECK(!isValidEntryName("../evil.wav")); + CHECK(!isValidEntryName("dir/inner.wav")); + CHECK(!isValidEntryName("dir\\inner.wav")); + CHECK(!isValidEntryName("/rooted.wav")); + CHECK(!isValidEntryName("\\rooted.wav")); + // Embedded NUL: every plausible filesystem call (ofstream, fopen, + // CreateFileW off .c_str()) truncates at it, so two names differing only + // after the NUL would collide on one file. + CHECK(!isValidEntryName(std::string("a\0b.wav", 7))); + // Other control bytes (newline here) are equally hostile to logs/UI. + CHECK(!isValidEntryName("a\nb.wav")); +} + +static void testEntryNameRejectsAbsolutePrefixes() { + CHECK(!isValidEntryName("C:\\abs.wav")); + CHECK(!isValidEntryName("C:/abs.wav")); + CHECK(!isValidEntryName("c:relative-to-drive.wav")); // ':' bans drive forms + CHECK(!isValidEntryName("\\\\server\\share.wav")); // UNC + CHECK(!isValidEntryName(std::string(kMaxEntryNameBytes + 1, 'x'))); // over cap +} + +static void testEntryNameRejectsWindowsHostileNames() { + // Reserved characters. + CHECK(!isValidEntryName("a*b.wav")); + CHECK(!isValidEntryName("a?b.wav")); + CHECK(!isValidEntryName("a|b.wav")); + CHECK(!isValidEntryName("a.wav")); + CHECK(!isValidEntryName("\"q\".wav")); + // Trailing dot or space (silently stripped at creation on Windows). + CHECK(!isValidEntryName("trailing ")); + CHECK(!isValidEntryName("trailing.")); + CHECK(!isValidEntryName(" ")); + CHECK(!isValidEntryName(" ")); + // DOS device names, case-insensitive, with and without an extension. + CHECK(!isValidEntryName("NUL")); + CHECK(!isValidEntryName("CON")); + CHECK(!isValidEntryName("con.wav")); + CHECK(!isValidEntryName("PRN")); + CHECK(!isValidEntryName("AUX")); + CHECK(!isValidEntryName("COM1")); + CHECK(!isValidEntryName("com1.txt")); + CHECK(!isValidEntryName("LPT1")); + // The superscript device forms (COM¹ COM² COM³ LPT¹ LPT² LPT³ in UTF-8): + // Windows reads those as digits in a device name, so "COM².wav" is COM2. + CHECK(!isValidEntryName("COM\xC2\xB9.wav")); + CHECK(!isValidEntryName("com\xC2\xB2")); + CHECK(!isValidEntryName("COM\xC2\xB3.wav")); + CHECK(!isValidEntryName("LPT\xC2\xB9")); + CHECK(!isValidEntryName("lpt\xC2\xB2.txt")); + CHECK(!isValidEntryName("LPT\xC2\xB3.wav")); + // Not a device name: a real filename that merely starts with one, and the + // zero forms, which Windows does not reserve. + CHECK(isValidEntryName("console.wav")); + CHECK(isValidEntryName("COM0.wav")); + CHECK(isValidEntryName("LPT0")); + // Nor does a superscript past 3 name a device. + CHECK(isValidEntryName("COM\xE2\x81\xB4.wav")); // U+2074 SUPERSCRIPT FOUR +} + +// --- isValidEntryName: UTF-8 well-formedness --------------------------------- + +static void testEntryNameAcceptsWellFormedUtf8() { + CHECK(isValidEntryName("caf\xC3\xA9.wav")); // 2-byte: é + CHECK(isValidEntryName("\xE2\x99\xAA.wav")); // 3-byte: ♪ + CHECK(isValidEntryName("\xF0\x9F\x8E\xB5.wav")); // 4-byte: 🎵 + CHECK(isValidEntryName("\xEF\xBB\xBF.wav")); // U+FEFF, ugly but well-formed + CHECK(isValidEntryName("\xF4\x8F\xBF\xBF.wav")); // U+10FFFF, the last code point +} + +static void testEntryNameRejectsIllFormedUtf8() { + // Two names differing ONLY in their invalid bytes: a host converting to + // UTF-16 substitutes U+FFFD for both by default, collapsing them onto one + // file — the duplicate-name collision the manifest cannot otherwise see. + CHECK(!isValidEntryName("a\x80.wav")); // stray continuation byte + CHECK(!isValidEntryName("a\x81.wav")); + // Structural: truncated sequences (a lead byte the name ends inside). + CHECK(!isValidEntryName("a\xC3")); + CHECK(!isValidEntryName("a\xE2\x99")); + CHECK(!isValidEntryName("a\xF0\x9F\x8E")); + // A lead byte followed by a non-continuation. + CHECK(!isValidEntryName("a\xC3\x41.wav")); + // Overlong encodings: an alternate spelling of an ASCII byte we ban. + CHECK(!isValidEntryName("a\xC0\xAF.wav")); // overlong '/' + CHECK(!isValidEntryName("a\xC0\x80.wav")); // overlong NUL + CHECK(!isValidEntryName("a\xE0\x80\xAF.wav")); // overlong '/', 3-byte + CHECK(!isValidEntryName("a\xF0\x80\x80\xAF.wav")); // overlong '/', 4-byte + // Surrogate halves: no code point, and unrepresentable in UTF-16. + CHECK(!isValidEntryName("a\xED\xA0\x80.wav")); // U+D800 + CHECK(!isValidEntryName("a\xED\xBF\xBF.wav")); // U+DFFF + // Past U+10FFFF, and the 5/6-byte leads that never encode anything. + CHECK(!isValidEntryName("a\xF4\x90\x80\x80.wav")); // U+110000 + CHECK(!isValidEntryName("a\xF5\x80\x80\x80.wav")); + CHECK(!isValidEntryName("a\xFC\x80\x80\x80\x80\x80.wav")); + CHECK(!isValidEntryName("a\xFF.wav")); +} + +// --- sameEntryName ----------------------------------------------------------- + +static void testSameEntryNameFoldsAsciiCase() { + // A bank authored on a case-sensitive filesystem produces this pair + // honestly; Windows and default APFS would extract both onto one file. + CHECK(sameEntryName("Kick.wav", "kick.wav")); + CHECK(sameEntryName("KICK.WAV", "kick.wav")); + CHECK(sameEntryName("kick.wav", "kick.wav")); + CHECK(!sameEntryName("kick.wav", "snare.wav")); + CHECK(!sameEntryName("kick.wav", "kick.wave")); // length alone decides + CHECK(!sameEntryName("", "a")); + CHECK(sameEntryName("", "")); + // ASCII only: "é" vs "É" are two names here (the NFC/NFD limitation this + // shares — see this directory's CLAUDE.md). + CHECK(!sameEntryName("caf\xC3\xA9.wav", "caf\xC3\x89.wav")); + // Only the letters fold — the bytes flanking the ASCII range must not. + CHECK(!sameEntryName("a[b", "a{b")); // 0x5B vs 0x7B, 'Z'+1 and 'z'+1 + CHECK(!sameEntryName("a@b", "a`b")); // 0x40 vs 0x60, 'A'-1 and 'a'-1 +} + +// --- isValidNestedSamplePath ------------------------------------------------- + +static void testNestedSamplePathAcceptsRelative() { + CHECK(isValidNestedSamplePath("a.wav")); + CHECK(isValidNestedSamplePath("reasampler_bank/kick.wav")); + CHECK(isValidNestedSamplePath("reasampler_bank\\kick.wav")); + CHECK(isValidNestedSamplePath("deep/dir/tree/a.wav")); + // A ".." that is not a whole component is an ordinary name. + CHECK(isValidNestedSamplePath("take..final/a.wav")); + CHECK(isValidNestedSamplePath("bank/..hidden")); + CHECK(isValidNestedSamplePath("a..b")); +} + +static void testNestedSamplePathRejectsTraversalAndAbsolute() { + // BankModel::add catches only the absolute forms, so traversal reaches the + // format unless this rule stops it. + CHECK(!isValidNestedSamplePath("..")); + CHECK(!isValidNestedSamplePath("../evil.wav")); + CHECK(!isValidNestedSamplePath("..\\evil.wav")); + CHECK(!isValidNestedSamplePath("bank/../../evil.wav")); + CHECK(!isValidNestedSamplePath("bank\\..\\evil.wav")); + CHECK(!isValidNestedSamplePath("bank/..")); + CHECK(!isValidNestedSamplePath("bank/../")); + // Everything util::isAbsolutePath already catches. + CHECK(!isValidNestedSamplePath("/rooted.wav")); + CHECK(!isValidNestedSamplePath("\\rooted.wav")); + CHECK(!isValidNestedSamplePath("C:/abs.wav")); + CHECK(!isValidNestedSamplePath("C:\\abs.wav")); + CHECK(!isValidNestedSamplePath("c:relative-to-drive.wav")); + CHECK(!isValidNestedSamplePath("\\\\server\\share.wav")); +} + +int main() { + testClassifyReadable(); + testClassifyTooNew(); + testClassifyMalformed(); + testEntryNameAccepts(); + testEntryNameRejectsSeparatorsAndDots(); + testEntryNameRejectsAbsolutePrefixes(); + testEntryNameRejectsWindowsHostileNames(); + testEntryNameAcceptsWellFormedUtf8(); + testEntryNameRejectsIllFormedUtf8(); + testSameEntryNameFoldsAsciiCase(); + testNestedSamplePathAcceptsRelative(); + testNestedSamplePathRejectsTraversalAndAbsolute(); + + if (g_fail == 0) { + std::printf("package_format_tests: all passed\n"); + return 0; + } + std::printf("package_format_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_package_io.cpp b/tests/test_package_io.cpp new file mode 100644 index 0000000..eaddba9 --- /dev/null +++ b/tests/test_package_io.cpp @@ -0,0 +1,350 @@ +// Standalone tests for shell/package/package_io — no REAPER, no framework. Failure is +// injected at the writer seam (abandonment, open failure, rename failure) rather than +// simulated, and the streaming claim is asserted against PayloadBuffer::alive(). + +#include "../src/shell/package/package_io.h" +#include "../src/shell/package/package_path.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace reasampler; +namespace fs = std::filesystem; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static std::vector patternBytes(std::size_t n, std::uint8_t seed) { + std::vector v(n); + for (std::size_t i = 0; i < n; ++i) + v[i] = static_cast(seed + i * 7u); + return v; +} + +static bool sameBytes(const PayloadBuffer& p, const std::vector& want) { + return p.size() == want.size() && + (want.empty() || std::equal(want.begin(), want.end(), p.data())); +} + +static void writeScratchFile(const std::string& path, + const std::vector& bytes) { + std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +static std::vector readAll(const std::string& path) { + std::ifstream f(utf8Path(path), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +static bool exists(const std::string& path) { return fs::exists(utf8Path(path)); } + +static void removeQuietly(const std::string& path) { + std::error_code ec; + fs::remove(utf8Path(path), ec); +} + +static void testPayloadCounterTracksMovesNotCopies() { + CHECK(PayloadBuffer::alive() == 0); + { + PayloadBuffer a(patternBytes(4, 1)); + CHECK(PayloadBuffer::alive() == 1); + PayloadBuffer b = std::move(a); + CHECK(PayloadBuffer::alive() == 1); // the count moved with the bytes + PayloadBuffer c; + c = std::move(b); + CHECK(PayloadBuffer::alive() == 1); + CHECK(c.size() == 4); + } + CHECK(PayloadBuffer::alive() == 0); + { + PayloadBuffer empty; + CHECK(PayloadBuffer::alive() == 0); // holding nothing counts as nothing + } +} + +static void testStreamingRoundTripHoldsOnePayload() { + std::error_code destEc; + const std::string dest = + (fs::temp_directory_path(destEc) / "pkg_io_scratch.rsbank").generic_string(); + const std::vector header = patternBytes(16, 0xA0); + const std::vector> entries = { + patternBytes(1000, 1), patternBytes(500, 2), patternBytes(1, 3)}; + + std::vector srcs; + for (std::size_t i = 0; i < entries.size(); ++i) { + srcs.push_back("pkg_io_src" + std::to_string(i) + ".bin"); + writeScratchFile(srcs[i], entries[i]); + } + + CHECK(PayloadBuffer::alive() == 0); + std::vector> layout; // offset, length + { + PackageFileWriter writer(dest); + CHECK(writer.ok()); + CHECK(writer.appendRaw(header.data(), header.size())); + std::uint64_t offset = header.size(); + for (std::size_t i = 0; i < entries.size(); ++i) { + PayloadBuffer p = readFilePayload(srcs[i]); + CHECK(sameBytes(p, entries[i])); + CHECK(PayloadBuffer::alive() == 1); // exactly one entry in memory + CHECK(writer.appendPayload(p)); + layout.emplace_back(offset, p.size()); + offset += p.size(); + } + CHECK(PayloadBuffer::alive() == 0); // each released before the next + CHECK(writer.commit()); + CHECK(!writer.commit()); // a second commit is refused + } + CHECK(!exists(dest + ".rsbanktmp")); + CHECK(exists(dest)); + + { + // Scoped: the reader holds the file open, and Windows refuses to delete an + // open file — cleanup below needs it closed first. + PackageFileReader reader(dest); + CHECK(reader.ok()); + CHECK(reader.fileSize() == header.size() + 1000 + 500 + 1); + for (std::size_t i = 0; i < entries.size(); ++i) { + PayloadBuffer p = reader.readRange(layout[i].first, layout[i].second); + CHECK(PayloadBuffer::alive() == 1); // one entry per readRange, no more + CHECK(sameBytes(p, entries[i])); + } + CHECK(PayloadBuffer::alive() == 0); + } + + for (const std::string& s : srcs) removeQuietly(s); + removeQuietly(dest); +} + +static void testEmptyPayloadIsRefusedAndPoisonsTheWriter() { + // The failure this guards: an unreadable source yields an empty payload, and a + // verb that trusted a `true` here would commit framing claiming bytes nobody wrote. + const std::string dest = "pkg_io_emptypayload.rsbank"; + { + PackageFileWriter writer(dest); + const std::vector head = patternBytes(4, 1); + CHECK(writer.appendRaw(head.data(), head.size())); + CHECK(!writer.appendPayload(PayloadBuffer{})); + CHECK(!writer.ok()); + CHECK(!writer.commit()); // the short stream can never reach the destination + } + CHECK(!exists(dest)); + CHECK(!exists(dest + ".rsbanktmp")); + // A zero-length appendRaw stays tolerated — framing has legitimate empty edges. + { + PackageFileWriter writer(dest); + CHECK(writer.appendRaw(nullptr, 0)); + CHECK(writer.ok()); + writer.abort(); + } +} + +static void testAbandonedWriteLeavesNoDestination() { + const std::string dest = "pkg_io_abandon.rsbank"; + { + PackageFileWriter writer(dest); + const std::vector some = patternBytes(64, 9); + CHECK(writer.appendRaw(some.data(), some.size())); + // no commit — destruction is the injected interruption + } + CHECK(!exists(dest)); + CHECK(!exists(dest + ".rsbanktmp")); +} + +static void testAbortPreservesPriorContents() { + const std::string dest = "pkg_io_prior.rsbank"; + const std::vector prior = patternBytes(32, 0x40); + writeScratchFile(dest, prior); + { + PackageFileWriter writer(dest); + const std::vector some = patternBytes(64, 9); + CHECK(writer.appendRaw(some.data(), some.size())); + writer.abort(); + CHECK(!writer.appendRaw(some.data(), some.size())); // dead after abort + CHECK(!writer.commit()); + } + CHECK(readAll(dest) == prior); + CHECK(!exists(dest + ".rsbanktmp")); + removeQuietly(dest); +} + +static void testCommitReplacesAnExistingFile() { + // The module's one deliberate asymmetry against LandedFileJournal's never-overwrite + // rule: the save dialog's overwrite confirm is the consent, so commit() replaces. + const std::string dest = "pkg_io_replace.rsbank"; + const std::vector prior = patternBytes(40, 0x11); + const std::vector fresh = patternBytes(7, 0x22); + writeScratchFile(dest, prior); + { + PackageFileWriter writer(dest); + CHECK(writer.ok()); + CHECK(writer.appendRaw(fresh.data(), fresh.size())); + CHECK(writer.commit()); + } + CHECK(readAll(dest) == fresh); + CHECK(!exists(dest + ".rsbanktmp")); + removeQuietly(dest); +} + +static void testOpenFailureIsInert() { + const std::string dest = "pkg_io_no_such_dir/x.rsbank"; + PackageFileWriter writer(dest); + CHECK(!writer.ok()); + const std::vector some = patternBytes(8, 1); + CHECK(!writer.appendRaw(some.data(), some.size())); + CHECK(!writer.commit()); + CHECK(!exists("pkg_io_no_such_dir")); +} + +static void testCommitRenameFailureSelfCleans() { + // A directory squatting on the destination makes the final rename fail — a real + // injected commit failure, not a simulated one. + const std::string dest = "pkg_io_dir.rsbank"; + std::error_code ec; + fs::create_directory(utf8Path(dest), ec); + CHECK(!ec); + { + PackageFileWriter writer(dest); + CHECK(writer.ok()); + const std::vector some = patternBytes(8, 1); + CHECK(writer.appendRaw(some.data(), some.size())); + CHECK(!writer.commit()); + } + CHECK(fs::is_directory(utf8Path(dest))); // prior state intact + CHECK(!exists(dest + ".rsbanktmp")); + fs::remove(utf8Path(dest), ec); +} + +static void testReaderEdges() { + PackageFileReader missing("pkg_io_no_such_file.rsbank"); + CHECK(!missing.ok()); + CHECK(missing.readRange(0, 1).empty()); + + const std::string path = "pkg_io_edges.bin"; + const std::vector bytes = patternBytes(10, 5); + writeScratchFile(path, bytes); + { + // Scoped: the reader must be closed before the cleanup remove below. + PackageFileReader reader(path); + CHECK(reader.ok()); + CHECK(reader.fileSize() == 10); + CHECK(reader.readRange(5, 10).empty()); // past the end + CHECK(reader.readRange(10, 1).empty()); // starts at the end + CHECK(reader.readRange(0, 0).empty()); // zero length is failure, one branch + CHECK(sameBytes(reader.readRange(2, 3), + std::vector(bytes.begin() + 2, bytes.begin() + 5))); + } + removeQuietly(path); +} + +static void testFileStatusSeparatesAbsentFromUnreadable() { + CHECK(fileStatus("pkg_io_no_such_file.bin") == FileStatus::Absent); + + const std::string path = "pkg_io_status.bin"; + writeScratchFile(path, patternBytes(4, 1)); + CHECK(fileStatus(path) == FileStatus::Present); + removeQuietly(path); + + // A directory in a file's place is not a readable file — the export message must + // not report it as simply missing. + const std::string dir = "pkg_io_status_dir"; + std::error_code ec; + fs::create_directory(utf8Path(dir), ec); + CHECK(fileStatus(dir) == FileStatus::Unreadable); + fs::remove(utf8Path(dir), ec); +} + +static void testNonAsciiPathsRoundTripAsUtf8() { + // "café" in UTF-8. Windows decodes a narrow std::filesystem path through the ANSI + // code page, so an unconverted seam lands "café" or fails outright. + const std::string dir = "pkg_io_caf\xC3\xA9_dir"; + const std::string name = "caf\xC3\xA9.rsbank"; + std::error_code ec; + fs::create_directory(utf8Path(dir), ec); + CHECK(!ec); + + const std::string dest = dir + "/" + name; + const std::vector bytes = patternBytes(24, 0x5A); + { + PackageFileWriter writer(dest); + CHECK(writer.ok()); + CHECK(writer.appendRaw(bytes.data(), bytes.size())); + CHECK(writer.commit()); + } + CHECK(exists(dest)); + CHECK(fileStatus(dest) == FileStatus::Present); + CHECK(sameBytes(readFilePayload(dest), bytes)); + // The listing must hand the name back in the same encoding it was given. + CHECK(listFolderFileNames(dir) == (std::vector{name})); + + fs::remove_all(utf8Path(dir), ec); +} + +static void testWriteFileExclusiveRefusesAnOccupiedPath() { + const std::string path = "pkg_io_excl.bin"; + const std::vector mine = patternBytes(12, 3); + CHECK(writeFileExclusive(path, PayloadBuffer(mine))); + CHECK(readAll(path) == mine); + // The create IS the check: a second call cannot replace the first file's bytes. + // (this pins the refusal, not O_EXCL's atomicity — a genuine race isn't portable) + CHECK(!writeFileExclusive(path, PayloadBuffer(patternBytes(9, 8)))); + CHECK(readAll(path) == mine); + CHECK(!writeFileExclusive("pkg_io_excl_empty.bin", PayloadBuffer{})); + CHECK(!exists("pkg_io_excl_empty.bin")); + removeQuietly(path); + + // writeFileExclusive is the one call site using the wstring()/c_str() Windows + // open form rather than the fstream(fs::path) overload every other test here + // exercises — the only conversion whose reversion this file would otherwise miss. + const std::string cafePath = "pkg_io_excl_caf\xC3\xA9.bin"; + const std::vector cafeBytes = patternBytes(6, 4); + CHECK(writeFileExclusive(cafePath, PayloadBuffer(cafeBytes))); + CHECK(readAll(cafePath) == cafeBytes); + CHECK(!writeFileExclusive(cafePath, PayloadBuffer(patternBytes(3, 9)))); + removeQuietly(cafePath); +} + +static void testListFolderFileNames() { + const std::string dir = "pkg_io_listdir"; + std::error_code ec; + fs::create_directory(utf8Path(dir), ec); + writeScratchFile(dir + "/b.bin", patternBytes(2, 1)); + writeScratchFile(dir + "/a.bin", patternBytes(2, 2)); + fs::create_directory(utf8Path(dir + "/sub"), ec); + writeScratchFile(dir + "/sub/c.bin", patternBytes(2, 3)); + + const std::vector names = listFolderFileNames(dir); + CHECK(names == (std::vector{"a.bin", "b.bin"})); // sorted, bare, non-recursive + CHECK(listFolderFileNames("pkg_io_no_such_dir").empty()); + + fs::remove_all(utf8Path(dir), ec); +} + +int main() { + testPayloadCounterTracksMovesNotCopies(); + testStreamingRoundTripHoldsOnePayload(); + testEmptyPayloadIsRefusedAndPoisonsTheWriter(); + testAbandonedWriteLeavesNoDestination(); + testAbortPreservesPriorContents(); + testCommitReplacesAnExistingFile(); + testOpenFailureIsInert(); + testCommitRenameFailureSelfCleans(); + testReaderEdges(); + testFileStatusSeparatesAbsentFromUnreadable(); + testNonAsciiPathsRoundTripAsUtf8(); + testWriteFileExclusiveRefusesAnOccupiedPath(); + testListFolderFileNames(); + + if (g_fail == 0) std::printf("package_io: all tests passed\n"); + else std::printf("package_io: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_package_manifest.cpp b/tests/test_package_manifest.cpp new file mode 100644 index 0000000..f644f22 --- /dev/null +++ b/tests/test_package_manifest.cpp @@ -0,0 +1,373 @@ +// Standalone tests for reasampler::package's manifest codec — no REAPER, no +// test framework. The round-trip fixture exercises every manifest field and +// every Sample optional in both present and absent states; the rejection suite +// pins the naming, case-folding and traversal rules on encode AND decode. + +#include "../src/core/package/package_manifest.h" + +#include +#include +#include + +using namespace reasampler::package; +using namespace reasampler::model; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- fixtures ---------------------------------------------------------------- + +// Every Sample field populated, every optional PRESENT. +static Sample fullSample() { + Sample s; + s.id = "smp-full"; + s.displayName = "Kick (wet)"; + s.relativePath = "reasampler_bank/kick.wav"; + s.sourceMode = SourceMode::RazorArea; + s.sourceRange = {1.25, 3.5, 480.0, 1920.0}; + s.trackGuids = {"{AAA}", "{BBB}"}; + s.wetDry = 0.75; + s.channelCount = 2; + s.sampleRate = 48000; + s.lengthSeconds = 2.25; + s.lengthBeats = 4.5; + s.captureTempo = 120.5; + s.captureTimeSigNum = 7; + s.captureTimeSigDenom = 8; + s.key = "F#m"; + s.rootNote = 60; + s.loop = LoopPoints{100, 4800}; + s.levels = {-0.3, -12.7, -14.0}; + s.clipped = true; + s.tier = Tier::Archive; + s.contentHash = "W0123456789abcdef"; + s.provenance = Provenance{"smp-parent", "fx-snapshot"}; + s.createdTimestamp = 1754000000; + return s; +} + +// Every Sample optional ABSENT (key, rootNote, loop, provenance). +static Sample bareSample() { + Sample s; + s.id = "smp-bare"; + s.displayName = "Snare"; + s.relativePath = "reasampler_bank/snare.wav"; + s.sourceMode = SourceMode::Realtime; + s.contentHash = "Wfedcba9876543210"; + s.createdTimestamp = 1754000001; + return s; +} + +static PackageManifest fixture() { + PackageManifest m; + m.bankDisplayName = "Drums \"live\""; // escaping exercised + m.exportTimestamp = 1754100000; + m.entries.push_back({"kick.wav", 96000, "1111222233334444", fullSample()}); + m.entries.push_back({"snare.wav", 48000, "5555666677778888", bareSample()}); + m.slots.append("smp-full"); + m.slots.append("smp-bare"); + m.slots.remove("smp-full"); // leaves a gap: slots round-trip must keep it + return m; +} + +// --- round trip -------------------------------------------------------------- + +static void testRoundTripEveryField() { + const PackageManifest m = fixture(); + auto json = serializeManifest(m); + CHECK(json.has_value()); + auto back = deserializeManifest(*json); + CHECK(back.has_value()); + CHECK(*back == m); + // Spot-check both optional states survived (== above proves it; these name + // the claim so a failure reads directly). + CHECK(back->entries[0].sample.loop.has_value()); + CHECK(back->entries[0].sample.provenance.has_value()); + CHECK(!back->entries[1].sample.key.has_value()); + CHECK(!back->entries[1].sample.rootNote.has_value()); + CHECK(back->slots.idAt(0).empty()); // the slot gap survived + CHECK(back->slots.slotOf("smp-bare") == 1); +} + +static void testEmptyManifestRoundTrips() { + PackageManifest m; + auto json = serializeManifest(m); + CHECK(json.has_value()); + auto back = deserializeManifest(*json); + CHECK(back.has_value()); + CHECK(*back == m); +} + +// --- forward compatibility --------------------------------------------------- + +static void testUnknownKeysSkippedAtEveryLevel() { + // A future additive manifest: unknown keys at the root, inside an entry, + // and inside the nested index blob itself. + const std::string json = + "{\"bankName\":\"B\",\"exported\":7," + "\"instrumentState\":{\"nested\":[1,2,{\"x\":\"y\"}]}," + "\"entries\":[{\"name\":\"a.wav\",\"length\":10,\"hash\":\"h\"," + "\"futureField\":\"ignored\"," + "\"index\":{\"version\":1,\"futureIndexField\":42,\"samples\":[{\"id\":\"s1\"," + "\"relativePath\":\"bank/a.wav\"}]}}]," + "\"slots\":[],\"trailingUnknown\":null}"; + auto m = deserializeManifest(json); + CHECK(m.has_value()); + CHECK(m->bankDisplayName == "B"); + CHECK(m->exportTimestamp == 7); + CHECK(m->entries.size() == 1); + CHECK(m->entries[0].fileName == "a.wav"); + CHECK(m->entries[0].byteLength == 10); + CHECK(m->entries[0].sample.id == "s1"); +} + +// --- rejection: entry names, both directions --------------------------------- + +static void testEncodeRejectsBadEntryName() { + for (const char* bad : {"..\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\a.wav", "", ".."}) { + PackageManifest m = fixture(); + m.entries[0].fileName = bad; + CHECK(!serializeManifest(m).has_value()); + } +} + +// One entry, `name` spliced in as raw manifest text so a hostile spelling +// (escapes included) is expressible — a package is not limited to what encode +// emits. +static std::string oneEntryJson(const std::string& name, + const std::string& relativePath = "bank/a.wav") { + return "{\"entries\":[{\"name\":\"" + name + + "\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\"," + "\"relativePath\":\"" + relativePath + "\"}]}}]}"; +} + +static void testDecodeRejectsBadEntryName() { + for (const char* bad : {"..\\\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\\\a.wav", ".."}) + CHECK(!deserializeManifest(oneEntryJson(bad)).has_value()); + + // The rest of the rule set through decode — the direction that matters, + // since a package can arrive from anywhere. + CHECK(!deserializeManifest(oneEntryJson("CON.wav")).has_value()); // DOS device + CHECK(!deserializeManifest(oneEntryJson("COM\xC2\xB2.wav")).has_value()); // COM² + CHECK(!deserializeManifest(oneEntryJson("a.wav ")).has_value()); // trailing space + CHECK(!deserializeManifest(oneEntryJson("a.wav.")).has_value()); // trailing dot + CHECK(!deserializeManifest(oneEntryJson("a*b.wav")).has_value()); // reserved char + // A NUL smuggled in as a JSON escape: the manifest text is legal, the + // decoded name is not. + CHECK(!deserializeManifest(oneEntryJson("a\\u0000b.wav")).has_value()); + // Ill-formed UTF-8 as raw bytes. + CHECK(!deserializeManifest(oneEntryJson("a\xC3.wav")).has_value()); + // A lone surrogate never reaches the name rule — the JSON reader refuses + // the unpaired \uD800 first. Pinned so that refusal cannot silently become + // "decoded to U+FFFD and accepted". + CHECK(!deserializeManifest(oneEntryJson("a\\ud800b.wav")).has_value()); +} + +static void testDecodeRejectsTraversalInNestedPath() { + // The one field in the format that CAN express a path. BankModel::add + // catches the absolute forms only, so ".." arrives unless the package layer + // refuses it. + CHECK(!deserializeManifest(oneEntryJson("a.wav", "../../evil.wav")).has_value()); + CHECK(!deserializeManifest(oneEntryJson("a.wav", "bank/../evil.wav")).has_value()); + CHECK(!deserializeManifest(oneEntryJson("a.wav", "..")).has_value()); + // A ".." that is not a whole component still reads. + CHECK(deserializeManifest(oneEntryJson("a.wav", "take..final/a.wav")).has_value()); +} + +static void testEncodeRejectsTraversalInNestedPath() { + PackageManifest m = fixture(); + m.entries[0].sample.relativePath = "../../evil.wav"; + CHECK(!serializeManifest(m).has_value()); + + PackageManifest m2 = fixture(); + m2.entries[0].sample.relativePath = "bank/../evil.wav"; + CHECK(!serializeManifest(m2).has_value()); +} + +static void testDuplicateEntryNamesRejectedBothWays() { + PackageManifest m = fixture(); + m.entries[1].fileName = m.entries[0].fileName; + CHECK(!serializeManifest(m).has_value()); + + // Case-folded: two names one case-insensitive filesystem extracts onto a + // single file are one name here too, in both directions. + PackageManifest folded = fixture(); + folded.entries[1].fileName = "KICK.WAV"; // entries[0] is "kick.wav" + CHECK(!serializeManifest(folded).has_value()); + + // Decode side, from a hand-built duplicate (a hostile package is not + // limited to what encode emits). + const std::string dup = + "{\"entries\":[" + "{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}," + "{\"name\":\"A.WAV\",\"length\":2,\"hash\":\"i\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}"; + CHECK(!deserializeManifest(dup).has_value()); + + // Two names that differ outside the ASCII letters are still two names. + const std::string distinct = + "{\"entries\":[" + "{\"name\":\"a1.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}," + "{\"name\":\"a2.wav\",\"length\":2,\"hash\":\"i\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}"; + CHECK(deserializeManifest(distinct).has_value()); +} + +static void testRepeatedRootKeyRejected() { + // A repeated "entries" must not accumulate into two arrays' worth of + // entries; the other three assign rather than append, but "which duplicate + // keys are legal" is one format answer, not four. + const std::string entriesTwice = + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]," + "\"entries\":[{\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}"; + CHECK(!deserializeManifest(entriesTwice).has_value()); + + CHECK(!deserializeManifest("{\"bankName\":\"A\",\"bankName\":\"B\"}").has_value()); + CHECK(!deserializeManifest("{\"exported\":1,\"exported\":2}").has_value()); + CHECK(!deserializeManifest("{\"slots\":[],\"slots\":[]}").has_value()); + // Unknown keys stay repeatable: they are skipped, and a future format must + // be free to add them. + CHECK(deserializeManifest("{\"future\":1,\"future\":2}").has_value()); + + // The same answer one level down, so a hostile manifest cannot make two + // readers disagree about which spelling of an entry field is the real one. + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}") + .has_value()); + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"length\":2,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}") + .has_value()); + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\",\"hash\":\"i\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}") + .has_value()); + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}") + .has_value()); +} + +// --- rejection: structural --------------------------------------------------- + +// See src/core/package/CLAUDE.md for the shell seam that forces this. +static void testEncodeRejectsZeroLengthEntry() { + PackageManifest m = fixture(); + m.entries[0].byteLength = 0; + CHECK(!serializeManifest(m).has_value()); +} + +// The asymmetry is deliberate: refusing a zero-length entry is an obligation on +// what this layer WRITES, not a claim about what a package may declare. Pinned +// so it is not "fixed" into a decode-side rejection. +static void testDecodeAcceptsZeroLengthEntry() { + auto m = deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":0,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}"); + CHECK(m.has_value()); + CHECK(m->entries.size() == 1); + CHECK(m->entries[0].byteLength == 0); +} + +static void testEncodeRejectsUnrepresentableSample() { + PackageManifest m = fixture(); + m.entries[0].sample.id.clear(); // BankModel::add rejects an empty id + CHECK(!serializeManifest(m).has_value()); + + PackageManifest m2 = fixture(); + m2.entries[0].sample.relativePath = "C:/abs/kick.wav"; // and an absolute path + CHECK(!serializeManifest(m2).has_value()); +} + +static void testDecodeRejectsMalformedShapes() { + CHECK(!deserializeManifest("").has_value()); + CHECK(!deserializeManifest("not json").has_value()); + CHECK(!deserializeManifest("[]").has_value()); + CHECK(!deserializeManifest("{\"entries\":[{}]}").has_value()); // entry missing fields + // Missing one required entry field apiece. + CHECK(!deserializeManifest( + "{\"entries\":[{\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}") + .has_value()); + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}") + .has_value()); + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}") + .has_value()); + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"}]}").has_value()); + // Negative length. + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":-1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}") + .has_value()); + // A nested index that is not exactly one sample (zero and two). + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[]}}]}").has_value()); + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}," + "{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}").has_value()); + // A nested sample BankModel::add drops (absolute path) fails the entry — + // the silent drop must not half-parse into an empty index. + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\"," + "\"relativePath\":\"C:/abs.wav\"}]}}]}").has_value()); + // An out-of-range enum inside the nested Sample blob fails the entry: the + // manifest defines no enum of its own, and bank_model's codec REJECTS an + // unknown sourceMode/tier rather than degrading — so growing one of those + // vocabularies is a minReaderVersion bump, not an additive change (see this + // directory's CLAUDE.md). + CHECK(!deserializeManifest( + "{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"," + "\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"," + "\"sourceMode\":99}]}}]}").has_value()); + // Trailing garbage after the root object. + auto json = serializeManifest(fixture()); + CHECK(json.has_value()); + CHECK(!deserializeManifest(*json + "x").has_value()); + // Trailing garbage after the EMPTY-object shortcut specifically: this path + // returned early before reaching the eof check, so "{}JUNK" parsed valid. + CHECK(!deserializeManifest("{}JUNK").has_value()); + CHECK(deserializeManifest("{}").has_value()); + // Truncation at a few JSON-level offsets (byte-level truncation of the whole + // package is bank_package's suite). + CHECK(!deserializeManifest(json->substr(0, json->size() / 2)).has_value()); + CHECK(!deserializeManifest(json->substr(0, 1)).has_value()); +} + +int main() { + testRoundTripEveryField(); + testEmptyManifestRoundTrips(); + testUnknownKeysSkippedAtEveryLevel(); + testEncodeRejectsBadEntryName(); + testDecodeRejectsBadEntryName(); + testDecodeRejectsTraversalInNestedPath(); + testEncodeRejectsTraversalInNestedPath(); + testDuplicateEntryNamesRejectedBothWays(); + testRepeatedRootKeyRejected(); + testEncodeRejectsZeroLengthEntry(); + testDecodeAcceptsZeroLengthEntry(); + testEncodeRejectsUnrepresentableSample(); + testDecodeRejectsMalformedShapes(); + + if (g_fail == 0) { + std::printf("package_manifest_tests: all passed\n"); + return 0; + } + std::printf("package_manifest_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_package_rollback.cpp b/tests/test_package_rollback.cpp new file mode 100644 index 0000000..6f68bfc --- /dev/null +++ b/tests/test_package_rollback.cpp @@ -0,0 +1,195 @@ +// Standalone tests for shell/package/package_rollback — no REAPER, no framework. +// Pins both halves of the deletion discriminator: the structural half (only an +// exclusively-created path is recorded, and the record is absolute so a CWD change +// cannot re-aim it) and the contract half (markIndexCommitted disarms rollback). + +#include "../src/shell/package/package_rollback.h" +#include "../src/shell/package/package_path.h" + +#include +#include +#include +#include +#include +#include + +using namespace reasampler; +namespace fs = std::filesystem; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// The journal records absolute paths, so every expectation is built the same way. +static std::string scratch(const std::string& name) { + return pathToUtf8(fs::current_path() / utf8Path(name)); +} + +static std::vector patternBytes(std::size_t n, std::uint8_t seed) { + std::vector v(n); + for (std::size_t i = 0; i < n; ++i) + v[i] = static_cast(seed + i * 7u); + return v; +} + +static void writeScratchFile(const std::string& path, + const std::vector& bytes) { + std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +static std::vector readAll(const std::string& path) { + std::ifstream f(utf8Path(path), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +static bool exists(const std::string& path) { return fs::exists(utf8Path(path)); } + +static void removeQuietly(const std::string& path) { + std::error_code ec; + fs::remove(utf8Path(path), ec); +} + +static void testLandRecordsOnSuccessOnly() { + LandedFileJournal journal; + const std::string path = scratch("rb_land.bin"); + const std::vector bytes = patternBytes(32, 1); + CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes))); + CHECK(readAll(path) == bytes); + CHECK(journal.landedPaths().size() == 1); + CHECK(!exists(path + ".rsbanktmp")); // the land is a direct exclusive create + journal.rollback(); + CHECK(!exists(path)); // the recorded path denoted the file we asked for +} + +static void testLandNonAsciiPathRoundTripsAsUtf8() { + // The fs::absolute -> u8string round trip at writeLandedFile is otherwise + // untested with a non-ASCII path. + LandedFileJournal journal; + const std::string path = scratch("rb_caf\xC3\xA9.bin"); + const std::vector bytes = patternBytes(16, 2); + CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes))); + CHECK(readAll(path) == bytes); + journal.rollback(); + CHECK(!exists(path)); +} + +static void testRelativeInputIsRecordedAbsolute() { + // The hazard: a bare name recorded verbatim, then a CWD change, and rollback + // unlinks whatever now sits at that name in the new directory. + LandedFileJournal journal; + CHECK(journal.writeLandedFile("rb_relative.bin", PayloadBuffer(patternBytes(8, 4)))); + CHECK(journal.landedPaths().size() == 1); + const std::string recorded = journal.landedPaths().front(); + CHECK(utf8Path(recorded).is_absolute()); + std::error_code ec; + // Absolute AND still the same file — a spelling check alone would not prove that. + CHECK(fs::equivalent(utf8Path(recorded), utf8Path(scratch("rb_relative.bin")), ec)); + CHECK(!ec); + journal.rollback(); + CHECK(!exists(scratch("rb_relative.bin"))); +} + +static void testExistingDestinationRefusedUntouched() { + LandedFileJournal journal; + const std::string path = scratch("rb_existing.bin"); + const std::vector original = patternBytes(16, 0x60); + writeScratchFile(path, original); + + CHECK(!journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1)))); + CHECK(readAll(path) == original); // never overwritten + CHECK(journal.empty()); // a refused write is not recorded + + const RollbackResult result = journal.rollback(); + CHECK(result.deletedCount == 0); + CHECK(exists(path)); // rollback cannot touch a file it did not write + removeQuietly(path); +} + +static void testEmptyPayloadRefused() { + LandedFileJournal journal; + const std::string path = scratch("rb_empty.bin"); + CHECK(!journal.writeLandedFile(path, PayloadBuffer{})); + CHECK(!exists(path)); + CHECK(journal.empty()); +} + +static void testRollbackDeletesExactlyTheRecordedSet() { + LandedFileJournal journal; + const std::string a = scratch("rb_a.bin"); + const std::string c = scratch("rb_c.bin"); + const std::string bystander = scratch("rb_bystander.bin"); + CHECK(journal.writeLandedFile(a, PayloadBuffer(patternBytes(8, 1)))); + CHECK(journal.writeLandedFile(c, PayloadBuffer(patternBytes(8, 2)))); + writeScratchFile(bystander, patternBytes(8, 3)); // not journal-written + + const RollbackResult result = journal.rollback(); + CHECK(result.deletedCount == 2); + CHECK(result.alreadyAbsentCount == 0); + CHECK(result.failedCount == 0); + CHECK(!result.refused); + CHECK(!exists(a)); + CHECK(!exists(c)); + CHECK(exists(bystander)); // exactly the given files, nothing else + CHECK(journal.empty()); + + const RollbackResult second = journal.rollback(); // cleared: a no-op + CHECK(second.deletedCount == 0); + CHECK(exists(bystander)); + removeQuietly(bystander); +} + +static void testVanishedFileIsToleratedNotFailed() { + LandedFileJournal journal; + const std::string path = scratch("rb_gone.bin"); + CHECK(journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1)))); + removeQuietly(path); // vanished between land and rollback + CHECK(!exists(path)); + + const RollbackResult result = journal.rollback(); + CHECK(result.deletedCount == 0); + CHECK(result.alreadyAbsentCount == 1); + CHECK(result.failedCount == 0); +} + +static void testIndexCommitDisarmsRollback() { + // Once the index references these files the carve-out no longer covers them, so a + // late failure in the verb must not be able to delete indexed bytes. + LandedFileJournal journal; + const std::string path = scratch("rb_committed.bin"); + const std::vector bytes = patternBytes(8, 1); + CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes))); + + journal.markIndexCommitted(); + CHECK(journal.indexCommitted()); + + const RollbackResult result = journal.rollback(); + CHECK(result.refused); + CHECK(result.deletedCount == 0); + CHECK(readAll(path) == bytes); // untouched + CHECK(!journal.empty()); // the record survives the refusal + + // Landing more files after the commit would produce unrollbackable state. + const std::string late = scratch("rb_late.bin"); + CHECK(!journal.writeLandedFile(late, PayloadBuffer(patternBytes(8, 2)))); + CHECK(!exists(late)); + + removeQuietly(path); +} + +int main() { + testLandRecordsOnSuccessOnly(); + testLandNonAsciiPathRoundTripsAsUtf8(); + testRelativeInputIsRecordedAbsolute(); + testExistingDestinationRefusedUntouched(); + testEmptyPayloadRefused(); + testRollbackDeletesExactlyTheRecordedSet(); + testVanishedFileIsToleratedNotFailed(); + testIndexCommitDisarmsRollback(); + + if (g_fail == 0) std::printf("package_rollback: all tests passed\n"); + else std::printf("package_rollback: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/tests/test_package_round_trip.cpp b/tests/test_package_round_trip.cpp new file mode 100644 index 0000000..79f365b --- /dev/null +++ b/tests/test_package_round_trip.cpp @@ -0,0 +1,275 @@ +// The corpus driven through both verbs — no REAPER, no framework. Where +// test_package_compat asserts what the frozen bytes DECODE to, this file asserts what +// the import and export verbs DO with them: the payload bytes survive a full +// export -> import -> export, and every refusal in the corpus refuses before a planner +// or a filesystem write is reached. + +#include "../src/shell/package/import_landing.h" + +#include +#include +#include +#include +#include +#include + +#include "../src/core/package/bank_package.h" +#include "../src/shell/package/export_bank.h" +#include "../src/shell/package/package_path.h" +#include "../src/shell/persist/session.h" +#include "package_fixtures.h" + +using namespace reasampler; +namespace fs = std::filesystem; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static const char* kTag = "1754000000"; +static const char* kImportedBankId = "bank-imported"; + +// --- scratch project --------------------------------------------------------- + +// One project directory per suite, torn down after, so no suite observes another's +// bank folder. +class Scratch { +public: + explicit Scratch(const std::string& name) { + std::error_code ec; + dir_ = pathToUtf8(fs::temp_directory_path(ec) / + utf8Path("reasampler_compat_" + name)); + fs::remove_all(utf8Path(dir_), ec); + fs::create_directories(utf8Path(dir_), ec); + } + ~Scratch() { + std::error_code ec; + fs::remove_all(utf8Path(dir_), ec); + } + const std::string& projectDir() const { return dir_; } + std::string bankDir() const { return package::bankFolderDir(dir_); } + std::string importPath() const { return dir_ + "/in.rsbank"; } + std::string exportPath() const { return dir_ + "/out.rsbank"; } + +private: + std::string dir_; +}; + +static std::vector readBytes(const std::string& path) { + std::ifstream f(utf8Path(path), std::ios::binary); + return std::vector(std::istreambuf_iterator(f), + std::istreambuf_iterator()); +} + +static void writeBytes(const std::string& path, const std::vector& bytes) { + std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(bytes.data()), + static_cast(bytes.size())); +} + +// Copies a committed fixture to the path the verb will be pointed at. Fails loudly on a +// size mismatch (empty included): an unreadable OR mangled corpus would otherwise let +// every "must refuse" suite pass, since a garbled fixture still refuses, just not for +// the reason under test. +static bool stageFixture(const Scratch& scratch, const char* fixture, + std::size_t expectedSize) { + const std::vector bytes = packageFixtureBytes(fixture); + if (bytes.size() != expectedSize) { + std::printf("FAIL: fixture %s is %zu bytes, expected %zu (path: %s)\n", fixture, + bytes.size(), expectedSize, packageFixturePath(fixture).c_str()); + ++g_fail; + return false; + } + writeBytes(scratch.importPath(), bytes); + return true; +} + +// Each entry's payload bytes, sliced out of a whole package file by its own layout. +static std::vector> payloadsOf(const std::vector& file) { + std::vector> out; + const package::DecodedPackage dec = package::decodePackage(file, file.size()); + if (dec.status != package::PackageReadability::Readable) return out; + for (const package::PackageEntrySpan& span : dec.layout) { + const auto begin = file.begin() + static_cast(span.offset); + out.emplace_back(begin, begin + static_cast(span.length)); + } + return out; +} + +// --- the sequence the import verb runs, minus REAPER ------------------------- + +static ImportLanding runImport(const Scratch& scratch, ReaSamplerSession& session, + int* outBirths = nullptr) { + LandedFileJournal journal; + ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(), + session.book(), kTag, journal); + if (landing.outcome != ImportOutcome::Landed) return landing; + int births = 0; + const bool applied = applyImportedBank(session.book(), kImportedBankId, landing.plan, + [&births](const model::Sample&) { ++births; }); + CHECK(applied); + if (applied) journal.markIndexCommitted(); + if (outBirths) *outBirths = births; + return landing; +} + +// --- the round-trip anchor --------------------------------------------------- + +// The fixture IS the first export (written by the shipping build's own export verb), so +// importing and re-exporting it closes export -> import -> export over frozen bytes. +static void testV1FixtureReExportsByteIdenticalPayloads() { + Scratch scratch("roundtrip"); + if (!stageFixture(scratch, "v1_shipping.rsbank", 1207)) return; + + ReaSamplerSession session; + int births = 0; + const ImportLanding landing = runImport(scratch, session, &births); + CHECK(landing.outcome == ImportOutcome::Landed); + CHECK(births == 1); + CHECK(landing.plan.landCount == 1); + if (landing.plan.entries.size() != 1) { CHECK(false); return; } + + const std::vector> sourcePayloads = + payloadsOf(packageFixtureBytes("v1_shipping.rsbank")); + if (sourcePayloads.size() != 1) { CHECK(false); return; } + + // The landed file is the package's payload verbatim — the first half of the claim. + const std::string landed = scratch.bankDir() + "/" + landing.plan.entries[0].destFileName; + CHECK(readBytes(landed) == sourcePayloads[0]); + + ExportRequest req; + req.projectDir = scratch.projectDir(); + req.bankId = kImportedBankId; + req.destAbsPath = scratch.exportPath(); + req.exportTimestamp = 1754200000; + const ExportOutcome out = exportBank(session, req); + CHECK(out.status == ExportStatus::Written); + CHECK(out.entriesWritten == 1); + + // The second half: the re-export's payloads, byte for byte. Entry NAMES may legally + // differ across the trip — the importer re-spells a bank file and the exporter mints + // its own transport name (src/core/package/CLAUDE.md) — the payload bytes may not. + CHECK(payloadsOf(readBytes(scratch.exportPath())) == sourcePayloads); +} + +// --- the refusals, at the verb rather than the codec ------------------------- + +static void testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter() { + Scratch scratch("refuse"); + if (!stageFixture(scratch, "refuse_structural.rsbank", 1207)) return; + + ReaSamplerSession session; + const BankBook before = session.book(); + LandedFileJournal journal; + const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(), + session.book(), kTag, journal); + + CHECK(landing.outcome == ImportOutcome::TooNew); + // Literal, not kPackageFormatVersion-relative: this is the FIXTURE's frozen pair + // (2/2), not this build's (see test_package_compat.cpp's file header note). + CHECK(landing.header.formatVersion == 2); + CHECK(landing.header.minReaderVersion == 2); + CHECK(landing.header.writerVersion == "1.9.0"); + // Nothing planned, nothing on disk, nothing in the index. + CHECK(landing.plan.entries.empty()); + CHECK(!fs::exists(utf8Path(scratch.bankDir()))); + CHECK(session.book() == before); +} + +// The same eight cuts test_package_compat classifies, driven through the verb's +// incremental prefix reader — the one caller that can ask requiredPrefixSize for more +// bytes than the file holds. Sizes match test_package_compat.cpp's kTruncations. +struct TruncationFixture { + const char* file; + std::size_t size; +}; + +static const TruncationFixture kTruncationFixtures[] = { + {"trunc_magic.rsbank", 2}, + {"trunc_version_pair.rsbank", 10}, + {"trunc_writer_semver.rsbank", 18}, + {"trunc_manifest_length.rsbank", 23}, + {"trunc_manifest_body.rsbank", 466}, + {"trunc_payload_start.rsbank", 907}, + {"trunc_payload_middle.rsbank", 1057}, + {"trunc_one_short.rsbank", 1206}, +}; + +static void testEveryTruncationRefusesTheImportAsMalformed() { + for (const TruncationFixture& fixture : kTruncationFixtures) { + Scratch scratch(std::string("trunc_") + fixture.file); + if (!stageFixture(scratch, fixture.file, fixture.size)) continue; + + ReaSamplerSession session; + const BankBook before = session.book(); + LandedFileJournal journal; + const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(), + session.book(), kTag, journal); + if (landing.outcome != ImportOutcome::Malformed) { + std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture.file, + static_cast(landing.outcome)); + ++g_fail; + } + CHECK(landing.plan.entries.empty()); + CHECK(!fs::exists(utf8Path(scratch.bankDir()))); + CHECK(session.book() == before); + } +} + +// Sizes match test_package_compat.cpp's kHostiles. +struct HostileFixtureFile { + const char* file; + std::size_t size; +}; + +static const HostileFixtureFile kHostileFixtures[] = { + {"hostile_name_dotdot.rsbank", 624}, + {"hostile_name_parent_slash.rsbank", 633}, + {"hostile_name_parent_backslash.rsbank", 634}, + {"hostile_name_subdir_slash.rsbank", 634}, + {"hostile_name_drive_absolute.rsbank", 643}, + {"hostile_name_unc_absolute.rsbank", 646}, + {"hostile_path_dotdot_slash.rsbank", 641}, + {"hostile_path_dotdot_backslash.rsbank", 640}, + {"hostile_path_rooted.rsbank", 635}, + {"hostile_path_drive_absolute.rsbank", 643}, + {"hostile_path_unc_absolute.rsbank", 646}, +}; + +static void testEveryHostileNameIsRefusedBeforeThePlannerRuns() { + for (const HostileFixtureFile& fixture : kHostileFixtures) { + Scratch scratch(std::string("hostile_") + fixture.file); + if (!stageFixture(scratch, fixture.file, fixture.size)) continue; + + ReaSamplerSession session; + const BankBook before = session.book(); + LandedFileJournal journal; + const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(), + session.book(), kTag, journal); + if (landing.outcome != ImportOutcome::Malformed) { + std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture.file, + static_cast(landing.outcome)); + ++g_fail; + } + // planImport is the ONLY producer of a non-empty plan and it runs after the + // decode — an empty one is how "refused before any planner" is observed here. + CHECK(landing.plan.entries.empty()); + CHECK(landing.plan.bankDisplayName.empty()); + CHECK(!fs::exists(utf8Path(scratch.bankDir()))); + CHECK(session.book() == before); + } +} + +int main() { + testV1FixtureReExportsByteIdenticalPayloads(); + testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter(); + testEveryTruncationRefusesTheImportAsMalformed(); + testEveryHostileNameIsRefusedBeforeThePlannerRuns(); + + if (g_fail == 0) { + std::printf("package_round_trip_tests: all passed\n"); + return 0; + } + std::printf("package_round_trip_tests: %d failure(s)\n", g_fail); + return 1; +} diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index e524eb3..952f964 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -65,9 +65,11 @@ static void testRealtimeIsUnsupportedOffline() { } static void testEveryRenderSourceLabelIsPinnedVerbatim() { - // docs/VERIFICATION.md asks Daniel to report the refusal's `Render source:` line - // back verbatim, so every label is pinned to its literal — a typo in any of them - // breaks the report that quotes it, and only a literal catches that. + // docs/VERIFICATION.md's short-render bullet asks Daniel to report the refusal + // line back verbatim, and that line always carries the render source + // (render_bounds_gate.cpp appends "Render source: