52394fa08f
Ε-F1 proprietary RSBK; Ε-F2 always a new bank with an auto suffix; Ε-F3 refuse import on a degraded ledger.
729 lines
46 KiB
Markdown
729 lines
46 KiB
Markdown
# Bank package — product notes
|
||
|
||
Framing, rationale, and design-direction calls behind **Phase Ε — bank export and
|
||
import as a single-file package**. The tickable spec lives in `docs/PLAN.md`
|
||
(§Phase Ε); the architecture detail will live in `src/core/package/CLAUDE.md` and
|
||
`src/shell/package/CLAUDE.md` once those directories exist. This doc holds the
|
||
*why* — the user problem, the container choice, the version-compatibility policy
|
||
and the reasoning that produced it, the failure-mode table, and what a package
|
||
deliberately does not carry.
|
||
|
||
Status: framed by product-designer (2026-08-02); **all three [Daniel]-class forks
|
||
RULED the same day** — Ε-F1 **proprietary container** (`RSBK`), Ε-F2 **import always
|
||
lands as a new bank**, with an automatic suffix on a name collision, Ε-F3 **refuse an
|
||
import while the tracking ledger is degraded**. See §"Rulings" for the index and the
|
||
recorded rationale; each is specified in place in the section that owns it. Nothing in
|
||
this doc is open. Everything else below is a product-designer call with its reasoning
|
||
stated; contradict it in review with an argument, not a preference.
|
||
|
||
---
|
||
|
||
## What it is (and what it is not)
|
||
|
||
**A bank package is one file that carries one bank — its audio and its index —
|
||
out of a project and into another.** Today a bank is per-project by construction:
|
||
the audio sits in `<projectDir>/reasampler_bank/` (`core/capture/capture_paths.h:16`,
|
||
`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`,
|
||
`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.
|
||
|
||
**It is not a project-transfer feature.** REAPER already moves projects — *Save
|
||
project as… with copy of media*, track templates, subprojects. None of them can
|
||
carry a ReaSampler bank, because none of them knows the ext-state index exists;
|
||
copy the `reasampler_bank/` folder by hand into another project and you get a pile
|
||
of `.wav` files with no names, no loop points, no root notes, no tempo stamps, no
|
||
tiers, and no lineage. The package exists precisely because **the metadata is the
|
||
part that cannot be moved by hand.**
|
||
|
||
**It is not a preset.** A package carries audio plus bank metadata. It does not
|
||
carry ReaSampler 9000's dialed sound — filter, envelopes, splines, loop crossfade,
|
||
rate, pitch. That is the instrument's `ComponentState`, and a user who wants the
|
||
dialed sound in another project bakes it first (Phase Ξ's resample) and exports the
|
||
resulting capture. The package is a *bank*, and the bank has always been the audio,
|
||
not the instrument. See "What a package deliberately does not carry" below — this
|
||
is the most likely user expectation mismatch in the whole feature, so it is headed
|
||
off here rather than discovered in a support thread.
|
||
|
||
**It is not a re-encode.** Sample bytes leave the source project and arrive at the
|
||
destination byte-identical. Frame count, sample rate, bit depth, channel count are
|
||
untouched; no trim, no normalize, no mono collapse, no format conversion, no
|
||
compression of the audio payload. The package payload is **opaque bytes** to
|
||
everything in the export/import path except a hash function. This is the phase's
|
||
trust anchor, and it is the direct analogue of the capture pillar's null test.
|
||
|
||
---
|
||
|
||
## Why a single file, not a folder copy
|
||
|
||
The obvious cheap alternative is "copy the bank folder, and write the index into a
|
||
sidecar JSON beside it." Rejected, for four reasons, in descending order of weight:
|
||
|
||
1. **A folder has no place to put its own manifest that a user cannot lose.** The
|
||
index is the part that makes the audio a bank. In a folder, the manifest is just
|
||
one more file among two hundred `.wav`s — droppable, renamable, editable into
|
||
inconsistency, and silently absent after a partial copy. In a single file it is
|
||
the header, and the file either has one or is not a package.
|
||
2. **Integrity and version tagging need one identity.** "Is this package complete,
|
||
and can this build read it?" is answerable in one read of one file's first few
|
||
kilobytes. A folder answers it only after enumerating and stat-ing every entry,
|
||
and answers "was anything edited since export?" not at all.
|
||
3. **The move gesture is one object.** Email it, drop it in shared storage, drop it
|
||
on the docked panel. The panel already accepts `WM_DROPFILES` for ingest
|
||
(`src/shell/panel/panel_window.cpp` header comment: "WM_DROPFILES -> ingest"), so
|
||
a package can ride an affordance that exists.
|
||
4. **Atomicity is buyable.** A single file can be written to a temp path and
|
||
atomically renamed on success — the precedent the mono collapse already set
|
||
(Ψ-W2-T2 landed the collapse "via temp file plus atomic rename"). A half-written
|
||
folder looks exactly like a complete one.
|
||
|
||
The counter-argument for the folder is real and should be recorded: a folder is
|
||
inspectable with no tooling. The container ruling below does **not** buy that back —
|
||
`RSBK` is opaque without our tool — so the inspectability loss is an accepted cost,
|
||
paid deliberately, not an oversight to be corrected later by reaching for ZIP.
|
||
|
||
---
|
||
|
||
## The container — a proprietary `RSBK` (Ε-F1, RULED)
|
||
|
||
**Ruled by Daniel, 2026-08-02: *"proprietary container."*** The package is a
|
||
hand-rolled `RSBK` file. ZIP — whether via the vendored MiniZip64 in
|
||
`vendor/WDL/WDL/zlib/` or as a hand-written stored-only ZIP *shape* — is rejected and
|
||
is not to be revisited inside this phase.
|
||
|
||
**The shape.** Magic `RSBK`, a fixed little-endian header carrying the two version
|
||
fields (§"Version tagging" below), a length-prefixed JSON manifest, then each entry's
|
||
payload concatenated in manifest order. Framing overhead is tens of bytes, not
|
||
kilobytes.
|
||
|
||
**What it reuses, rather than invents.** The little-endian byte codec
|
||
(`core/wire/bytes.h` — `putLE` / `ByteReader`, called out in `src/core/wire/CLAUDE.md`
|
||
as the earned template case) and the hand-rolled JSON layer (`core/json`). Both are
|
||
already owned and already tested here.
|
||
|
||
**Why the ruling went this way.** The load-bearing reason is not effort — it is that
|
||
the pure/shell split is this project's central discipline, and `RSBK` is the only
|
||
candidate where the **whole codec lands pure** and the shell is a bytes-in/bytes-out
|
||
skin. MiniZip's API is path-and-file-handle shaped (`ioapi.h`), so a ZIP codec drags
|
||
the filesystem into the layer that discipline keeps free of hosts; a buffer-backed
|
||
`zlib_filefunc_def` is possible but fiddly and defeats the "standard format" argument
|
||
inside the code even while preserving it on disk. Compression buys almost nothing on
|
||
float32 PCM. Writing a correct ZIP central directory by hand was the worst of both —
|
||
more code than `RSBK`, the same hardening burden, plus Zip64 and name-encoding edge
|
||
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
|
||
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
|
||
it ships, and a later container change means either a second reader forever or
|
||
stranded packages. The version ladder below, not a format swap, is how the format
|
||
moves from here.
|
||
|
||
---
|
||
|
||
## Version tagging: two questions, and why one number cannot answer both
|
||
|
||
### The precedent this extends (read from source, 2026-08-02)
|
||
|
||
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
|
||
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
|
||
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` —
|
||
`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
|
||
triple only, no channel suffix). It is informational: an absent stamp is "not an
|
||
error and not a warning" (`app_version.h:166-168`).
|
||
|
||
**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
|
||
parser skips the key along with every other unknown one
|
||
(`bank_book_json.cpp:182` — `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.
|
||
|
||
### The two questions a package must answer
|
||
|
||
- **"Can I parse this shape at all?"** — a hard gate. Monotonic integer. This is
|
||
`origin_ledger`'s `"v"`.
|
||
- **"Who wrote this, so I can tell the user what to open it with?"** — informational,
|
||
never a gate. Semver string. This is `app_version`'s stamp.
|
||
|
||
A package carries **both**, and conflating them is the mistake to avoid. The stamp
|
||
alone cannot gate (semver ordering does not track schema shape; a patch release can
|
||
change a blob and a minor release can leave it alone). The ladder alone cannot
|
||
advise (an integer tells a user nothing about which build to install).
|
||
|
||
### The refinement: `formatVersion` **and** `minReaderVersion`
|
||
|
||
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`,
|
||
"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
|
||
a defined absent-value. Under a single ladder, each would have blocked older readers
|
||
for no reason.
|
||
|
||
So the package header carries two integers:
|
||
|
||
- **`formatVersion`** — what this writer emitted. Monotonic, bumped on any change.
|
||
- **`minReaderVersion`** — the oldest reader that can read this package *safely*.
|
||
Bumped only when a change is **structural** (a field's meaning changes, a section
|
||
is removed, framing changes); left alone when a change is **additive** (a new
|
||
optional manifest key, a new enum value with a defined degrade).
|
||
|
||
The reader's rule is one line: **read it iff
|
||
`minReaderVersion <= kPackageFormatVersion`.** `formatVersion` is then only for the
|
||
message text and the log.
|
||
|
||
This is a borrowed pattern, not an invention: Matroska's `EBMLVersion` /
|
||
`EBMLReadVersion` pair, PDF's catalog `/Version` over the header version, and OOXML's
|
||
`mc:Ignorable` markup-compatibility mechanism all separate "what I am" from "what you
|
||
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
|
||
append").
|
||
|
||
### Both directions, concretely
|
||
|
||
**Direction 1 — newer ReaSampler, older package. Always imports. Never refuses.**
|
||
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
|
||
skipped, which is already how every parser in this repo behaves
|
||
(`bank_book_json.cpp:182`). Unrecognized enum integers 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
|
||
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
|
||
not an incident.
|
||
|
||
**Direction 2 — older ReaSampler, newer package. Refuses. Whole-package, nothing
|
||
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
|
||
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`,
|
||
`int (*ShowMessageBox)(const char* msg, const char* title, int type)`) naming three
|
||
things, because any two of them leave the user stuck:
|
||
|
||
> **Cannot import this bank package.**
|
||
> It was written by ReaSampler 1.7.0 and needs package format 3 or newer.
|
||
> This build (1.5.2) reads package format 2.
|
||
> Nothing was imported. Install ReaSampler 1.7.0 or newer and try again.
|
||
|
||
The writer's semver is what makes the message *actionable* — "format 3" alone tells a
|
||
user nothing they can act on. That is the whole reason both fields exist.
|
||
|
||
**Refusing is the correct direction to refuse in**, and it is worth saying why
|
||
rather than leaving it as taste: the destination project is the user's existing work.
|
||
A refusal costs a transfer the user can retry after updating. A best-effort partial
|
||
import costs silent data absence inside a project they will keep working in, and they
|
||
will not find out which twelve of forty samples were dropped until they need one.
|
||
|
||
---
|
||
|
||
## What a package carries
|
||
|
||
- **The two version fields and the writer's semver**, in the fixed header.
|
||
- **An export timestamp** and the **source bank's display name** — informational, and
|
||
the default the import prompt pre-fills.
|
||
- **One manifest entry per sample**, carrying that `Sample` record in
|
||
**`bank_model`'s own serialization, nested verbatim**. This is the
|
||
`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`
|
||
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
|
||
hex). Note carefully: `hashBytes`, **not** `hashWavContent`. The latter deliberately
|
||
skips non-`fmt `/`data` chunks (`wav_codec.h:145-151`), 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
|
||
answering different questions.
|
||
- **The bank's slot map** — display positions (`core/model/slot_map`), already JSON
|
||
round-trippable. A bank's arrangement is part of what the user built.
|
||
- **The payloads**, byte-exact, in manifest order.
|
||
|
||
`hashBytes` is FNV-1a — a corruption detector, not a cryptographic checksum. Say so
|
||
plainly in the code and in any user-facing wording: it catches truncation, bit rot,
|
||
and a mangled transfer. It does not certify provenance, and it is not a defense
|
||
against a package deliberately crafted to collide. That is the right level of
|
||
guarantee for this feature; overselling it would be the error.
|
||
|
||
## What a package deliberately does NOT carry
|
||
|
||
- **Any absolute path. Any path at all.** Entries are **bare file names** — no
|
||
directory component, no `..`, no drive letter, no leading separator — validated on
|
||
encode *and* on decode. The importer spells the destination path itself, through
|
||
the same `capture_paths` arithmetic every capture already uses. This makes the
|
||
relative-paths-only precision invariant **structural rather than remembered**:
|
||
there is no field in the format capable of expressing an absolute path. It also
|
||
closes the archive-traversal ("zip slip") bug class by construction, which is the
|
||
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
|
||
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,
|
||
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
|
||
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
|
||
counter. A package is a bank, not a project.
|
||
- **ReaSampler 9000's `ComponentState`.** Stated above; restated here because it is
|
||
the expectation most likely to be wrong. The seam is left open, not closed: the
|
||
manifest skips unknown keys, so a future `instrumentState` section is a purely
|
||
additive change that does not bump `minReaderVersion`. Designing that seam now and
|
||
spending it later is the point.
|
||
|
||
---
|
||
|
||
## Identity and collision on import
|
||
|
||
**The import target is settled first, because it frames all four collisions.
|
||
Ε-F2, RULED by Daniel, 2026-08-02: *"always lands as a new bank, with an auto suffix
|
||
if name collision."*** Every import creates a **new bank** in the destination book.
|
||
It never merges into an existing bank, never lands into the pool, never offers a
|
||
target picker, and never overwrites. **Merge-into-existing is out of scope for Phase
|
||
Ε** — not deferred behind a flag, not a second action shipped later in this phase, not
|
||
a checkbox. A user who wants imported samples in an existing bank imports and then
|
||
uses the existing move/copy verbs, which already do exactly that and already carry
|
||
their own undo.
|
||
|
||
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.
|
||
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
|
||
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
|
||
one.
|
||
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
|
||
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
|
||
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
|
||
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.
|
||
|
||
### The auto-suffix rule (Ε-F2, implementation-binding)
|
||
|
||
The importer picks the destination bank's display name itself. The user is told what
|
||
it picked; the user is never asked.
|
||
|
||
**The seed.** The seed is the package's recorded source bank display name, taken
|
||
**verbatim**. If that name is absent, empty, or whitespace-only after the model's own
|
||
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
|
||
already carried by a bank in the destination book:
|
||
|
||
seed, seed + " 2", seed + " 3", seed + " 4", …
|
||
|
||
ascending from 2, unbounded. So `"Drums"` into a project already holding `"drums"`
|
||
lands as `"Drums 2"`; a third copy lands as `"Drums 3"`.
|
||
|
||
**Four properties that make this unambiguous, each stated because omitting it lets two
|
||
implementations diverge:**
|
||
|
||
1. **The seed is never re-parsed.** A package named `"Drums 2"` colliding in the
|
||
destination lands as `"Drums 2 2"`, **not** `"Drums 3"`. This is deliberate and is
|
||
not a defect to fix: a trailing integer cannot be distinguished from a user's own
|
||
name (`"Kit 808"` would become `"Kit 2"` under a stripping rule, silently losing
|
||
user-authored text). `resample_name::nextIterationName` may increment its tail only
|
||
because ` r<N>` carries a marker; a bare integer carries none. Appending is the
|
||
safe direction — it never mutates text the user wrote.
|
||
2. **The probe fills gaps.** With `"Drums"` and `"Drums 3"` present and `"Drums 2"`
|
||
free, the import lands as `"Drums 2"`. First-free-ascending, not
|
||
highest-plus-one — the rule is a pure function of the destination's current name
|
||
set, so the same package into the same project always produces the same name.
|
||
3. **The suffix is derived from the destination, never from the package.** The package
|
||
records only its source name. Nothing about a collision is stored in the package,
|
||
and re-importing the same package into a different project can produce a different
|
||
name. The probe terminates: with `B` banks in the destination, one of the first
|
||
`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
|
||
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
|
||
std::string& seed) const`, returning the first free candidate) and calls it. That
|
||
one member is the only edit any Ε track makes to `core/model/`.
|
||
|
||
**What the suffix does NOT touch.** It renames nothing but the new bank's display
|
||
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
|
||
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
|
||
pool is structurally a bank), and the destination's pool always exists and always
|
||
carries the protected name `"Pool"`. So a pool export imported anywhere lands as a
|
||
**named** bank called `"Pool 2"`. That is correct under the Ε-F2 ruling — import never
|
||
lands into the pool — and it should read as intended behaviour in the summary, not as
|
||
a glitch.
|
||
|
||
**What the user sees, and their recovery.** The import summary names the bank it
|
||
created, and says so plainly when the name was adjusted:
|
||
|
||
> Imported 42 samples into a new bank: **Drums 2** (a bank named "Drums" already
|
||
> exists in this project).
|
||
|
||
The recovery path is the existing rename verb — one Ctrl-Z undoes the whole import
|
||
including the bank creation, and a rename is one gesture if the user wants a different
|
||
name. Neither needs a new affordance.
|
||
|
||
---
|
||
|
||
## Failure modes and what the user sees
|
||
|
||
Whole-package, all-or-nothing on both sides. The reasoning is the same one prune
|
||
settled on: report before acting, and never leave a half-state that looks whole.
|
||
|
||
| Failure | Side | Behaviour | What the user sees |
|
||
|---|---|---|---|
|
||
| An indexed file is missing on disk | export | Refuse by default; offer "export the N present entries" only behind an explicit confirm that lists what is missing | Message box naming the missing entries; nothing written unless confirmed |
|
||
| An indexed file is unreadable (locked/permission) | export | Same as missing | Same, distinguishing unreadable from absent |
|
||
| 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 |
|
||
| Entry name contains a path separator, `..`, or is absolute | import | Refuse whole, before any write | "This package is not well-formed." Hostile input, not user error — no need to elaborate |
|
||
| Payload hash mismatch on any entry | import | Refuse whole, before landing anything | "This bank package is damaged (entry `<name>` failed its integrity check). Nothing was imported." |
|
||
| A write fails mid-import (disk full, permission) | import | Roll back: delete the files **this import wrote** and abandon the index mutation | "Import failed and was rolled back. Nothing was added." |
|
||
| Bank name collides in the destination | import | Auto-suffix, no prompt, no overwrite — first free of `seed`, `seed 2`, `seed 3`, … | Summary names the bank it created and says the name was adjusted |
|
||
| File name collides in the bank folder | import | Auto-rename, no prompt | Counted in the summary line only |
|
||
| Sample already present by content hash | import | Skip the write, collapse onto the existing entry | Counted in the summary line ("N already present") |
|
||
| Tracking ledger degraded at import time | import | **Refuse whole**, before the picker's bytes are read and before any write — the guard runs first | The two-case message below, mirroring prune's abort |
|
||
|
||
**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
|
||
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
|
||
exactly: the files were written by this call, and the index mutation is abandoned, so
|
||
nothing ever referenced them. The spec must **cite** the carve-out rather than
|
||
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
|
||
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
|
||
a user infer that Ctrl-Z cleans the folder.
|
||
|
||
### Import under a degraded tracking ledger (Ε-F3, RULED: refuse)
|
||
|
||
**Ruled by Daniel, 2026-08-02: *"refuse mismatched import."*** An import that cannot be
|
||
cleanly reconciled against the tracking ledger is **refused outright**. There is no
|
||
confirm-and-proceed path, no "I understand the risk" checkbox, and no preference to
|
||
turn the guard off. This ruling went against the framing recommendation, and the
|
||
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`).
|
||
`Fresh` (absent key — a legitimate new project) and `Loaded` both proceed normally.
|
||
|
||
**Two things the guard is deliberately NOT keyed on:**
|
||
|
||
- **Not `PruneReport::blockedByTracking`.** That flag also fires on unreadable
|
||
`rsusage_*` keys, which are about live-instance protection during a *deletion*.
|
||
Import deletes nothing and computes no protected set; it *writes* birth records. An
|
||
undecodable usage key must not block an import, and reusing prune's composite flag
|
||
would silently make it do so.
|
||
- **Not the package.** Nothing in the `.rsbank` participates in this check. The package
|
||
is untouched by a refusal and remains importable later, elsewhere, or after the
|
||
project is repaired.
|
||
|
||
**When it runs.** First — **before the file picker opens**, before a byte of the
|
||
package is read, before any allocation. Making the user find and pick a file we have
|
||
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
|
||
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
|
||
clears the wrong key and is still blocked. Two cases, exactly one of which fires:
|
||
|
||
*Malformed ledger:*
|
||
|
||
> ReaSampler import: ABORTED — the file-tracking ledger could not be read. Nothing was
|
||
> imported.
|
||
> The stored file-tracking ledger is malformed. It has been left intact rather than
|
||
> overwritten, so it can be repaired or cleared:
|
||
> `reaper.SetProjExtState(0, "reasampler", "owned_files", "")`
|
||
> 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.
|
||
> 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.
|
||
|
||
*Ledger from a newer build:*
|
||
|
||
> ReaSampler import: ABORTED — the file-tracking ledger could not be read. Nothing was
|
||
> imported.
|
||
> 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.
|
||
> 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.
|
||
|
||
**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
|
||
(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.
|
||
|
||
**Export is NOT gated on the ledger, and that asymmetry is intentional.** Export writes
|
||
no birth records, mutates nothing, and touches no ext state. A user whose ledger is
|
||
degraded can still get their bank *out* — which is exactly the moment they are most
|
||
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
|
||
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
|
||
confirm would also push a data-lifecycle consequence onto the user at the one moment
|
||
they are least equipped to evaluate it — mid-transfer, wanting the samples. The refusal
|
||
costs a retry after a project reload; the confirm costs a bank folder that can never be
|
||
reclaimed.
|
||
|
||
---
|
||
|
||
## Memory: the streaming seam that keeps the codec pure
|
||
|
||
A bank is not small. Float32 stereo at 48 kHz is ~23 MB per minute; a two-hundred-
|
||
sample bank is plausibly gigabytes. **The naive shape — a pure
|
||
`encodePackage(vector<uint8_t>) -> vector<uint8_t>` — holds the whole bank twice in
|
||
RAM and is unshippable.** The temptation is then to move the codec into the shell so
|
||
it can stream. That is the wrong correction, and the right one is a better seam:
|
||
|
||
- **Pure owns framing and arithmetic.** `encodeHeader(manifest) -> bytes` and
|
||
`entryLayout(manifest) -> [{ name, offset, length }]` on the write side;
|
||
`decodeHeader(prefix bytes) -> manifest + entry layout` on the read side. Offsets
|
||
and lengths are arithmetic — perfectly pure, perfectly testable, and the exact
|
||
place an off-by-one becomes a corrupt package.
|
||
- **Shell owns the stream.** It writes the header, then appends payloads one at a
|
||
time, reading each source file into a buffer, hashing it, writing it, and releasing
|
||
it. On decode it reads the prefix, gets the layout, then seeks and streams each
|
||
payload independently.
|
||
|
||
**Constraint, stated as an acceptance criterion:** the export and import paths hold
|
||
**at most one entry's payload** in memory at a time. This is what keeps the codec
|
||
pure without making the feature fail on real banks, and it is the kind of thing that
|
||
is cheap to design in and expensive to retrofit.
|
||
|
||
**One honest cost.** Export and import are synchronous, on the UI thread, like every
|
||
other action in the tool, and prune sets that precedent (a scan-then-confirm gesture
|
||
that blocks). A multi-gigabyte bank will therefore freeze REAPER for seconds. The
|
||
recommendation is to ship synchronous with a console progress/summary line and treat
|
||
async as a later move if it bites — but this is a real `[propose]`-class call the
|
||
implementation review should make deliberately rather than by default.
|
||
|
||
---
|
||
|
||
## Where it lives (pure / shell)
|
||
|
||
Two new directories, following the split the whole repo turns on.
|
||
|
||
**`src/core/package/` — pure, REAPER-free, unit-tested without a DAW.**
|
||
|
||
- `package_format` — the container framing and the version ladder in one place:
|
||
the magic, the header layout, `kPackageFormatVersion`, `kPackageMinReaderVersion`,
|
||
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.
|
||
- `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
|
||
above. Never holds a payload.
|
||
- `export_plan` — the pure export decision: which entries, what names, what is
|
||
missing, and therefore whether the export may proceed.
|
||
- `import_plan` — the pure import decision: the id remap table, the parent remap, the
|
||
per-entry write / skip-already-present / rename-to-avoid-collision disposition, and
|
||
the destination bank name after uniqueness folding. **This module is why the whole
|
||
feature is testable without a DAW** — every collision rule above is a pure function
|
||
over strings and hashes.
|
||
|
||
`export_plan` and `import_plan` are separate TUs deliberately, not one `package_plan`:
|
||
they share only the manifest type, and separating them is what lets the two Phase Ε
|
||
build tracks run in parallel without fighting over a file. The seam is a
|
||
responsibility seam, which is what the structural heuristic asks for.
|
||
|
||
**`src/shell/package/` — filesystem and REAPER-facing.**
|
||
|
||
- `package_io` — read a package file to bytes, write bytes through temp + atomic
|
||
rename, read a bank file's bytes, write a landed file, enumerate existing bank-folder
|
||
names, and execute the rollback delete (citing the `prune_fs` carve-out).
|
||
- The platform file pickers, under the `#ifdef _WIN32` / `#else swell/swell.h` split
|
||
this codebase already uses (`src/shell/panel/draw_kit.cpp:11-15`,
|
||
`src/shell/persist/prune_fs.cpp:35-38`). **Verified:** the REAPER API offers a *read*
|
||
picker — `GetUserFileNameForRead(char* filenameNeed4096, const char* title, const
|
||
char* defext)`, `reaper_plugin_functions.h:3798` — and **no save picker at all**
|
||
(a sweep of the header for `FileNameFor|SaveFile|Browse` returns only that one
|
||
entry). Export's destination picker therefore comes from Win32 `GetSaveFileNameW`
|
||
on Windows and SWELL's `BrowseForSaveFile` elsewhere
|
||
(`vendor/WDL/WDL/swell/swell-functions.h:167`). This is a real asymmetry between
|
||
the two verbs and the spec should not paper over 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`).
|
||
|
||
**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`
|
||
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
|
||
container down. A base class that grows a dependency must not grow its subclasses'
|
||
constructors.
|
||
|
||
---
|
||
|
||
## Invariant reconciliation
|
||
|
||
- **Relative paths only.** Strengthened, not merely preserved: the package format has
|
||
no field capable of expressing a path, only a bare file name, validated at both
|
||
ends. The destination path is spelled by `capture_paths` on the importing side.
|
||
- **Capture and placement are separate acts.** Import writes files and index entries.
|
||
It places **no** timeline item, ever — the same rule capture has always carried
|
||
(root `CLAUDE.md`, "The load-bearing principle"). A user who wants the imported
|
||
audio in the arrange uses the existing insert action.
|
||
- **Prune is the single exclusive file-deletion authority.** Unchanged. The one
|
||
rollback path is the documented self-cleanup carve-out, cited not restated.
|
||
- **No lossy transforms.** The payload is opaque bytes on both sides. `wav_codec` is
|
||
invoked on it only to hash and to read metadata already recorded — never to rebuild,
|
||
trim, normalize, or collapse. The mono collapse in particular is a **capture-path**
|
||
behaviour and must not reach the import path, for the same reason ingest is already
|
||
excluded from it (root `CLAUDE.md`, exact-bounds invariant: "ingest is excluded,
|
||
because an imported file is the user's bytes, not our capture"). A package's bytes
|
||
are someone else's capture; the same exclusion applies with the same reasoning.
|
||
- **Bit-identical round-trip.** Export → import → export yields byte-identical
|
||
payloads. This is the phase's trust anchor and belongs in the acceptance criteria of
|
||
the round-trip track, tested against frozen fixture bytes rather than against a
|
||
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
|
||
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
|
||
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.
|
||
|
||
---
|
||
|
||
## Rulings — Daniel's, 2026-08-02
|
||
|
||
All three [Daniel]-class forks this doc opened were ruled the same day it was framed.
|
||
Nothing here is open. This section is an index; each ruling is **specified** in the
|
||
section that owns it, and that section is the implementation-binding text.
|
||
|
||
| Fork | Ruling | Specified in |
|
||
|---|---|---|
|
||
| **Ε-F1** | **Proprietary container.** Hand-rolled `RSBK`. ZIP via the vendored MiniZip64, and a hand-written stored-only ZIP shape, are both rejected | §"The container" |
|
||
| **Ε-F2** | **Import always lands as a new bank**, with an automatic numeric suffix on a display-name collision. Merge-into-existing is out of scope for this phase | §"Identity and collision on import" — the frame, plus the auto-suffix rule |
|
||
| **Ε-F3** | **Refuse** an import while the tracking ledger is degraded. No confirm-and-proceed path | §"Import under a degraded tracking ledger" |
|
||
|
||
**Two of the three went to a different answer than the framing recommended, and the
|
||
reasons are worth keeping.** Ε-F2's recommendation was a *prompt* pre-filled with a
|
||
uniqueness-folded suggestion; the ruling removed the prompt entirely, which is the
|
||
better shape — the name is derived deterministically from the destination, the user is
|
||
told rather than asked, and the existing rename verb is the recovery. Ε-F3's
|
||
recommendation was allow-with-confirm; the ruling refused, and the counter-argument
|
||
raised alongside that recommendation is what carried it (scale — the accepted residual
|
||
contemplates one untracked capture, an import strands hundreds).
|
||
|
||
---
|
||
|
||
## Non-goals and guardrails
|
||
|
||
- **No auto-insertion of imported audio into the arrange.** Same rule as capture.
|
||
- **No overwrite of an existing bank-folder file, ever.** Auto-rename instead.
|
||
- **No partial import.** All-or-nothing, with rollback. A partially-imported bank is
|
||
the failure mode this whole design is shaped to avoid.
|
||
- **No re-encode, no trim, no normalize, no mono collapse on either side.**
|
||
- **No compression of the audio payload.** `RSBK` concatenates payload bytes; there is
|
||
no compressor in the path and none is to be added.
|
||
- **No merge-into-existing import.** Every import creates a new bank (Ε-F2). There is
|
||
no target picker, no "import into the active bank" variant, and no second action.
|
||
Move/copy already move samples between banks after the fact.
|
||
- **No instrument state in the package** — the seam is left additive, deliberately
|
||
unspent.
|
||
- **No whole-book export in this phase.** One package carries one bank, because that
|
||
is the unit users think in. A future multi-bank package is an additive manifest
|
||
change that does **not** bump `minReaderVersion`, so the option is preserved by
|
||
construction rather than by promise. Do not build it now.
|
||
- **Do not make the package a sync mechanism.** No "re-import to update," no
|
||
reconciliation against a previously-imported package, no package identity tracked
|
||
in project state. Import is a one-way copy-in. Anything else is a different product.
|