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

The per-directory CLAUDE.md count is re-derived at twenty-seven rather than
carried from either side. The "Decouple the instrument reload from VST3
activation" TODO entry does not survive: Γ-W3-T1 landed it, and COMPLETED.md
carries the discharge.
This commit is contained in:
2026-08-02 21:57:47 -04:00
139 changed files with 10870 additions and 1993 deletions
+1
View File
@@ -13,6 +13,7 @@ add_subdirectory(capture)
add_subdirectory(tracking)
add_subdirectory(reclaim)
add_subdirectory(version)
add_subdirectory(package)
add_subdirectory(view)
add_subdirectory(ui)
add_subdirectory(instrument)
+7 -2
View File
@@ -51,8 +51,8 @@ Detail specific to these pure modules:
- `capture_paths` — the REAPER-free path arithmetic behind offline capture: bank-subfolder + unique-filename derivation (`deriveBankPaths`, forward-slash form, no filesystem touch), the absolute-render-dir vs. project-relative-index-path split (`BankPaths`), the persist-side inverse (`resolveBankFile`, `projectDirOfRpp`), the Save-As bank-relocation plan (`deriveRelocationPlan`), and the GUID-primary project-identity classifier (`classifyProjectTransition` → `NoOp`/`Load`/`SaveAsRelocate`) the persist-poll timer drives.
- `capture_name` — the REAPER-free composition of one capture's label + file-stem base from its source-track name(s), a local-calendar discriminator (`MM-DD HHMM`, from the shell's clock read), and an optional batch ordinal. The label and the stem deliberately diverge: the stem still passes through `capture_paths::sanitizeStem` (so a name that sanitizes to nothing files as `capture`), while the label keeps the source name verbatim. Stem uniqueness stays entirely `makeUniqueTag`'s — this module never disambiguates.
- `insert_plan` — the REAPER-free logic behind the `insert` shell (M6): computes the `InsertMedia` `mode` bitmask from an `InsertOptions` struct (placement target, tempo-conform ratio, preserve-pitch flag), guaranteeing the &4 stretch-to-time-selection bit is never set and that no tempo bits are set when `conform == None`.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal).
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all.
- `render_settings` — the REAPER-free logic behind the capture action family: `SourceMode` → `RENDER_SETTINGS` bit mapping, `P_RAZOREDITS` string parsing + range-union bounds, razor-else-time range inference, the FX-scope bypass plan (`fxBypassPlanFor`), the one bounds mode a capture hands its window over on (`kRenderBoundsTimeSelection`) and the tail bit paired with it (`kTailFlagTimeSelection`), the tail-mode → `RENDER_TAILFLAG`/`RENDER_NORMALIZE`/`RENDER_TRIMEND` mapping (`tailRenderSettingsFor`) and its realtime-window analog (`realtimeRecordWindowEnd`), the capture-action taxonomy table (`captureActionTable`) `main.cpp` iterates to register the CAPTURE_ITEM/CAPTURE_TRACK family, and `renderSourceLabel` (the source named in the offline backend's bounds refusal).
- `render_window` — the REAPER-free frame arithmetic behind exact capture bounds: `frameCountFor` (the frame count a project-time window occupies at the project rate — the number the offline backend checks the rendered file against before landing it, so a render that printed something other than the window is refused rather than banked), `renderHonoredBounds` (the gate's verdict and the sole home of its one-frame tolerance, which is empirical rather than proven — the header states which renderer models it covers and which it does not), and `itemExtentPrintsWindow`, the predicate `render_settings::sourceModeForScope` consults to decide whether REAPER's selected-items render source can express a requested window at all. It also owns the one short-render diagnostic: `msFlooredEndFrameCount` (the frames a window holds with its end floored to the millisecond — the shape two live short renders matched on the retired custom-bounds mode, quoted by a refusal as a count coincidence and nothing more) and `isOnMillisecondGrid`, the whole-millisecond tolerance that count depends on.
- `track_topology` — the REAPER-free folder arithmetic over a project's flat `I_FOLDERDEPTH` delta list: `directChildIndices` names a folder parent's DIRECT children, the set `shell/capture/render_isolation` silences so a ranged item capture does not print its track's children. Grandchildren are excluded by construction — they reach the parent only through the child that owns them.
- `tail_control` — the REAPER-free logic behind the docked `bank_panel`'s tail-mode toggle: the cycle order (None → Auto → Manual → None), the Manual-length clamp/scroll-wheel fine-adjust (`clampManualMs`/`adjustManualMs`, 250 ms/notch, 2000 ms default), the toggle's label text (e.g. "Tail: Manual 2.0s"), and the `TailSetting` JSON round-trip persist stores per-project.
@@ -79,6 +79,11 @@ Detail specific to these pure modules:
that with a transient silencing (`shell/capture/render_isolation`) whose child-set
walk lives here in `track_topology`; the item-vs-track asymmetry behind it is in
`src/shell/capture/CLAUDE.md`.
- **The custom-time-bounds field floors the render window to the millisecond; the
time selection does not.** Both observations and why only one bounds mode is
reachable: `render_settings.h`'s `kRenderBoundsTimeSelection` — the one narrative
home; this bullet is a pointer, not a retelling. Do not reintroduce
`RENDER_BOUNDSFLAG=0`.
- `kRenderPreFaderStems` (&8192) is deliberately **not** used — REAPER offline
render has no true pre-FX "dry" bit; FX scoping is done entirely by the
FX-bypass-around-render mechanism, never by a render bit.
+15
View File
@@ -86,4 +86,19 @@ CaptureName composeCaptureName(const CaptureNameInputs& in) {
return out;
}
std::string captureTrackName(const std::string& sourceName) {
const std::string prefix(kCaptureTrackPrefix);
// A source with no readable name yields the bare word rather than a trailing
// space; both spellings are fixed points, which is what makes the whole function
// one (a track named exactly "Capture" must not become "Capture Capture"). Read
// from kCaptureTrackPrefixBare rather than chopped off prefix, so the two names
// can't drift out of sync with each other (both expand from the same header token).
const std::string bare = kCaptureTrackPrefixBare;
if (sourceName.empty()) return bare;
if (sourceName == bare) return sourceName;
if (sourceName.rfind(prefix, 0) == 0) return sourceName;
return prefix + sourceName;
}
} // namespace reasampler::capture
+22
View File
@@ -59,4 +59,26 @@ std::string formatCaptureStamp(const CaptureStamp& stamp);
CaptureName composeCaptureName(const CaptureNameInputs& in);
// The single source of truth for the word itself — kCaptureTrackPrefixBare and
// kCaptureTrackPrefix below both expand from this one token, so editing it can never
// desync captureTrackName's "no readable source name" bare-word fallback from the
// separator-terminated prefix it is derived from.
#define REASAMPLER_CAPTURE_TRACK_WORD "Capture"
// The bare word behind kCaptureTrackPrefix, needed by captureTrackName's
// no-readable-source-name fallback.
inline constexpr const char* kCaptureTrackPrefixBare = REASAMPLER_CAPTURE_TRACK_WORD;
// Prefixed onto a source track's name to name the track a render-in-place created.
// A display convention, not a persisted key — unlike a lane prefix or an action-id
// suffix, changing it later strands nothing.
inline constexpr const char* kCaptureTrackPrefix = REASAMPLER_CAPTURE_TRACK_WORD " ";
// The new track's name for a render of `sourceName`. IDEMPOTENT — a fixed point on
// its own output, so a second render over a result track yields "Capture MONEY"
// again rather than "Capture Capture MONEY". A counter suffix is deliberately not
// offered: REAPER does not uniquify track names either, and what distinguishes two
// renders of one source is their position, not their name.
std::string captureTrackName(const std::string& sourceName);
} // namespace reasampler::capture
+22 -12
View File
@@ -45,16 +45,25 @@ std::string sanitizeStem(const std::string& baseName) {
return out;
}
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag) {
const std::string dir = normalizeSlashes(projectDir);
RenderPaths deriveRenderPaths(const std::string& absoluteDir,
const std::string& baseName,
const std::string& uniqueTag) {
std::string stem = sanitizeStem(baseName);
if (!uniqueTag.empty()) {
stem += "_" + sanitizeStem(uniqueTag);
}
const std::string fileName = stem + ".wav";
RenderPaths r;
r.fileStem = stem; // stem only — REAPER appends the extension
r.fileName = stem + ".wav";
r.absoluteDir = normalizeSlashes(absoluteDir);
return r;
}
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag) {
const std::string dir = normalizeSlashes(projectDir);
// Precondition: caller must resolve a non-empty project directory — an
// empty one would otherwise fall back to a bare relative path (forbidden).
@@ -62,18 +71,19 @@ BankPaths deriveBankPaths(const std::string& projectDir,
// ignores it fails at the render/stat step, not silently onto CWD.
assert(!dir.empty() && "deriveBankPaths: projectDir must not be empty");
const RenderPaths r = deriveRenderPaths(
dir.empty() ? std::string{} : dir + "/" + kBankSubfolder, baseName, uniqueTag);
BankPaths p;
p.fileStem = stem; // stem only — REAPER appends extension
p.fileName = fileName;
p.relativePath = std::string(kBankSubfolder) + "/" + fileName;
p.absoluteDir = dir.empty() ? std::string{}
: dir + "/" + kBankSubfolder;
p.fileStem = r.fileStem;
p.fileName = r.fileName;
p.relativePath = bankRelativeForName(r.fileName);
p.absoluteDir = r.absoluteDir;
return p;
}
std::string bankRelativeForName(const std::string& fileName) {
if (fileName.empty()) return {};
// Same expression deriveBankPaths uses, so the two spellings can't drift.
return std::string(kBankSubfolder) + "/" + fileName;
}
+23 -3
View File
@@ -36,9 +36,29 @@ std::string normalizeSlashes(const std::string& path);
// "capture" if nothing usable remains. Deterministic.
std::string sanitizeStem(const std::string& baseName);
// Derives the bank paths for one capture: baseName is the sanitized file-stem
// source, uniqueTag an optional sanitized disambiguator (timestamp/counter) so
// repeated captures don't collide. Produces "<stem>[_<tag>].wav".
// Where one render writes, with no index spelling at all: the directory REAPER is
// told to render into plus the stem/file name it produces there. `absoluteDir` is
// taken as given (normalized only) rather than derived, because a render that never
// enters the bank has no bank subfolder to append — the render-in-place verb points
// this at the project's own recording path.
struct RenderPaths {
std::string absoluteDir; // RENDER_FILE (forward slash, no trailing slash)
std::string fileName; // <stem>.wav
std::string fileStem; // <stem> (RENDER_PATTERN — REAPER appends the extension)
};
// The file-stem spelling for one render: baseName is the sanitized file-stem source,
// uniqueTag an optional sanitized disambiguator (timestamp/counter) so repeated
// renders don't collide. Produces "<stem>[_<tag>].wav". THE one owner of that
// spelling — deriveBankPaths is expressed over it, and bankRelativeForName depends
// on the bank's spelling never drifting from it.
RenderPaths deriveRenderPaths(const std::string& absoluteDir,
const std::string& baseName,
const std::string& uniqueTag);
// Derives the bank paths for one capture: the same stem spelling as
// deriveRenderPaths, in the bank subfolder, plus the project-relative path the
// index stores.
BankPaths deriveBankPaths(const std::string& projectDir,
const std::string& baseName,
const std::string& uniqueTag);
+2 -2
View File
@@ -30,7 +30,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
// postprocessing bit clear. A fixed-threshold trim scales/limits/fades
// nothing, so identical requests trim at the identical sample -> holds
// the bit-identical-repeats invariant.
t.tailFlag = kTailFlagCustomBounds;
t.tailFlag = kTailFlagTimeSelection;
t.tailMs = kMaxTailMs;
t.normalize = kNormalizeTrimEnd;
t.trimEnd = autoTrimEndRatio();
@@ -38,7 +38,7 @@ TailRenderSettings tailRenderSettingsFor(TailMode mode, double manualTailMs) {
case TailMode::Manual:
// Clamped to the cap regardless of source; negative floors to 0.
t.tailFlag = kTailFlagCustomBounds;
t.tailFlag = kTailFlagTimeSelection;
t.tailMs = std::clamp(manualTailMs, 0.0, kMaxTailMs);
t.normalize = kNormalizeDisableAll;
t.trimEnd = 0.0;
+27 -11
View File
@@ -27,17 +27,34 @@ inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor e
// render wet; the scope decides which FX remain enabled.
inline constexpr int kRenderSingleFile = (4 << 16); // items/razor -> one file
// --- Render bounds mode -------------------------------------------------------
//
// A capture hands its window over on RENDER_BOUNDSFLAG=2 — the project's own TIME
// SELECTION (value verbatim, header ~3042), written through GetSet_LoopTimeRange.
//
// Custom time bounds (RENDER_BOUNDSFLAG=0, RENDER_STARTPOS/RENDER_ENDPOS, header
// ~3045-3046) must NOT be reintroduced: REAPER resolved a custom-bounds window on a
// whole-millisecond grid AT RENDER TIME, floored the end, wrote the floored value back
// over RENDER_ENDPOS, and rendered exactly the floored frame count — twice, to the
// frame. Re-rendering on this mode came back exact on both edges, including a start
// carrying a sub-millisecond remainder, which is what locates the floor in the
// custom-bounds field rather than downstream in the render engine. This is the one
// narrative home for that; other sites point here.
inline constexpr int kRenderBoundsTimeSelection = 2;
// --- Tail: RENDER_NORMALIZE / RENDER_TRIMEND bits + named constants ----------
//
// Every offline capture renders custom-time-bounds, so &1 (RENDER_TAILFLAG,
// header ~3047) is the only tail-flag bit that ever applies. RENDER_NORMALIZE
// (verbatim, header ~3051): &32768 = trim ending silence (Auto path);
// &(4<<16) = disable all render postprocessing (None/Manual path).
// RENDER_NORMALIZE (verbatim, header ~3051): &32768 = trim ending silence (Auto
// path); &(4<<16) = disable all render postprocessing (None/Manual path).
inline constexpr int kNormalizeTrimEnd = 32768; // &32768 trim ending silence
inline constexpr int kNormalizeDisableAll = (4 << 16); // &(4<<16) = 262144, disable all
inline constexpr int kTailFlagNone = 0;
inline constexpr int kTailFlagCustomBounds = 1; // &1, header ~3047
inline constexpr int kTailFlagNone = 0;
// RENDER_TAILFLAG's bits are keyed PER BOUNDS MODE (header ~3047): &4 is the
// time-selection mode's bit, the pair of kRenderBoundsTimeSelection above. A tail set
// under a different mode's bit renders no tail at all, so these two move together.
inline constexpr int kTailFlagTimeSelection = 4;
// Auto-trim trailing-silence threshold; single source of truth (RENDER_TRIMEND
// ratio derives from this dB, never the reverse). Daniel-set.
@@ -65,7 +82,7 @@ enum class TailMode {
// normalize bit is set (Auto). The backend reads these straight onto
// GetSetProjectInfo.
struct TailRenderSettings {
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or &1)
int tailFlag = kTailFlagNone; // RENDER_TAILFLAG (0 or the bounds mode's bit)
double tailMs = 0.0; // RENDER_TAILMS
int normalize = kNormalizeDisableAll; // RENDER_NORMALIZE
double trimEnd = 0.0; // RENDER_TRIMEND (only used when trim bit set)
@@ -100,12 +117,11 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
// bounds refusal: the two ways a render can miss its window — a source that
// derives its own bounds (selected items, razor edits) versus a time-bounded
// render that came up short — are indistinguishable from a frame count alone,
// and naming the source is what tells them apart in a bug report. Quoted verbatim
// in docs/VERIFICATION.md, which asks for this exact line back.
// and naming the source is what tells them apart in a bug report.
//
// MasterMix and TimeSelection deliberately answer the SAME words: they map to the
// same RENDER_SETTINGS value and every capture renders custom-time-bounded, so
// naming them apart would assert a render distinction that does not exist.
// same RENDER_SETTINGS value and render identically, so naming them apart would
// assert a render distinction that does not exist.
const char* renderSourceLabel(SourceMode mode);
// --- Capture scope: the FX-scope invariant ------------------------------------
+17
View File
@@ -15,6 +15,13 @@ long long frameIndexAt(double seconds, int sampleRate) {
return std::llround(seconds * static_cast<double>(sampleRate));
}
// See the header for why whole milliseconds get a tolerance and why it is this small.
double floorToMilliseconds(double seconds) {
const double ms = seconds * 1000.0;
if (isOnMillisecondGrid(seconds)) return std::nearbyint(ms) / 1000.0;
return std::floor(ms) / 1000.0;
}
} // namespace
long long frameCountFor(double startSeconds, double endSeconds, int sampleRate) {
@@ -41,4 +48,14 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd,
&& frameIndexAt(reqEnd, sampleRate) == frameIndexAt(itemEnd, sampleRate);
}
bool isOnMillisecondGrid(double seconds) {
const double ms = seconds * 1000.0;
return std::fabs(ms - std::nearbyint(ms)) < 1e-6;
}
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
int sampleRate) {
return frameCountFor(startSeconds, floorToMilliseconds(endSeconds), sampleRate);
}
} // namespace reasampler::capture
+35 -5
View File
@@ -1,7 +1,8 @@
#pragma once
// render_window — pure frame arithmetic for a capture's requested window: the
// frame count a project-time range occupies, and whether a render whose bounds
// come from the selected items' own extent already prints that window.
// render_window — pure frame arithmetic for a capture's requested window: the frame
// count a project-time range occupies, whether a render whose bounds come from the
// selected items' own extent already prints that window, and the one diagnostic a
// refused render quotes — whether its shortfall matches a millisecond-floor coincidence.
// NO REAPER types; unit-tested by tests/test_render_window.cpp.
namespace reasampler::capture {
@@ -25,8 +26,7 @@ long long frameCountFor(double startSeconds, double endSeconds, int sampleRate);
// end edge by DIFFERENT conventions can legitimately sit TWO frames from this answer
// (tests/test_render_window.cpp pins both facts). Which model REAPER uses is
// unverified, so a refusal one or two frames wide may be this gate's fault rather than
// the render's — the open DAW question in docs/VERIFICATION.md §Capture range and
// bounds. Widening past one frame retires the exact-bounds invariant rather than
// the render's. Widening past one frame retires the exact-bounds invariant rather than
// relaxing it, and is not a fix to reach for before that question is answered.
bool renderHonoredBounds(long long expectedFrames, long long actualFrames);
@@ -42,4 +42,34 @@ bool itemExtentPrintsWindow(double reqStart, double reqEnd,
double itemStart, double itemEnd,
int sampleRate);
// --- Diagnostics: where a short render lost its frames ------------------------
// The frames this window would hold if its END were resolved on a whole-millisecond
// grid, floored, instead of exactly. That is what REAPER's offline render did on the
// retired custom-time-bounds mode (render_settings.h's kRenderBoundsTimeSelection states
// the whole observation): two live short renders (48 kHz, TailMode::None) printed this
// count to the frame. Kept as the refusal's shape check — a refused render matching it
// says the floor is back, on a mode that was measured escaping it.
//
// Still a DESCRIPTION, never a request: nothing renders from this number and no capture
// path asks for it — a refusal quotes it to say the shortfall has the known shape, which
// is not the same as proving that this particular render took it. Whole-millisecond values
// are recognized within a nanosecond, because a decimal millisecond is not always one
// in binary (1.007 * 1000 lands just below 1007) and a bare floor would drop a
// millisecond from a window already on the grid. A nanosecond is far under one frame
// at any rate we render, so a real sub-millisecond remainder still floors.
//
// The tolerance is ours, not REAPER's: on a `1.007`-class grid point, a REAPER floor
// that does NOT carry the same epsilon would miss this shape entirely, and a real
// floored render would then read as an unmatched short render rather than the known one
// — silence here is not proof the floor didn't happen (docs/TODO.md records why this
// premise needs a DAW measurement before anything is built on it).
long long msFlooredEndFrameCount(double startSeconds, double endSeconds,
int sampleRate);
// True when `seconds` sits on a whole-millisecond boundary, under the nanosecond
// tolerance msFlooredEndFrameCount depends on and for the reason stated there. Public so
// that premise is testable directly rather than only through the count it feeds.
bool isOnMillisecondGrid(double seconds);
} // namespace reasampler::capture
+34
View File
@@ -25,4 +25,38 @@ std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
return children;
}
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex) {
const int count = static_cast<int>(folderDepths.size());
if (count == 0) return SiblingPlacement{};
const int src = srcIndex < 0 ? 0 : (srcIndex >= count ? count - 1 : srcIndex);
// levels[i] is track i's absolute nesting depth; levels[count] is the depth the
// list closes at (0 in a well-formed project). Negative is unrepresentable, so a
// malformed over-closing delta clamps here rather than propagating.
std::vector<int> levels(static_cast<std::size_t>(count) + 1, 0);
for (int i = 0; i < count; ++i) {
const int next = levels[static_cast<std::size_t>(i)] +
folderDepths[static_cast<std::size_t>(i)];
levels[static_cast<std::size_t>(i) + 1] = next < 0 ? 0 : next;
}
const int L = levels[static_cast<std::size_t>(src)];
int p = src + 1;
if (folderDepths[static_cast<std::size_t>(src)] >= 1) {
p = count; // an unterminated folder swallows the rest of the list
for (int j = src + 1; j <= count; ++j) {
if (levels[static_cast<std::size_t>(j)] == L) { p = j; break; }
}
}
SiblingPlacement out;
out.insertIndex = p;
out.precedingIndex = p - 1;
out.precedingDepth = L - levels[static_cast<std::size_t>(p - 1)];
out.newDepth = levels[static_cast<std::size_t>(p)] - L;
return out;
}
} // namespace reasampler::capture
+35 -3
View File
@@ -1,8 +1,8 @@
#pragma once
// track_topology — pure folder arithmetic over a project's track list: which tracks
// are the DIRECT children of a folder parent, derived from the I_FOLDERDEPTH deltas
// alone. NO REAPER types (the shell reads the deltas); unit-tested by
// tests/test_track_topology.cpp.
// are the DIRECT children of a folder parent, and where a new SIBLING of a given
// track goes, both derived from the I_FOLDERDEPTH deltas alone. NO REAPER types
// (the shell reads the deltas); unit-tested by tests/test_track_topology.cpp.
#include <vector>
@@ -21,4 +21,36 @@ namespace reasampler::capture {
std::vector<int> directChildIndices(const std::vector<int>& folderDepths,
int parentIndex);
// Where a new track goes so it is a SIBLING of `srcIndex` — same nesting level, same
// folder — and the two I_FOLDERDEPTH writes that put it there.
struct SiblingPlacement {
int insertIndex = 0; // the index the new track occupies after insertion
// The track that will PRECEDE the new one (insertIndex - 1), and its rewritten
// delta. -1 only for a degenerate empty list, where there is nothing to write.
int precedingIndex = -1;
int precedingDepth = 0;
int newDepth = 0; // the new track's own I_FOLDERDEPTH
};
// Both naive answers are audibly wrong, which is why this is arithmetic and not
// `srcIndex + 1`: inserting straight after a folder PARENT makes the new track that
// folder's first child (its audio re-enters the parent's FX and fader), and inserting
// straight after the folder's LAST track steals that track's closing delta and drops
// the new one outside the folder entirely (its audio bypasses the folder bus).
//
// Levels are absolute nesting depths recovered from the deltas (level[0] = 0,
// level[i+1] = level[i] + depth[i]). A folder parent's insert point is the first
// following track back at the source's own level — i.e. after the whole folder;
// everything else inserts directly below the source. On a well-formed delta list
// (one whose deltas sum to zero) the two writes preserve the total delta sum, so no
// track after the insertion changes level — the malformed case below does not carry
// that guarantee; the clamp keeps the result legal, not level-preserving.
//
// A malformed list (deltas not summing to zero, an out-of-range srcIndex) CLAMPS to
// the nearest legal placement rather than asserting: the failure mode of a corrupt
// project must be a track at the wrong nesting level, never a crash.
SiblingPlacement siblingPlacement(const std::vector<int>& folderDepths, int srcIndex);
} // namespace reasampler::capture
+18
View File
@@ -6,6 +6,7 @@
#include <cstdio> // std::snprintf (hash hex render)
#include <cstring> // std::memcpy, std::memcmp
#include <utility> // std::move
namespace reasampler::capture {
@@ -300,6 +301,23 @@ MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes) {
return out;
}
CollapsedWav applyMonoCollapse(std::vector<std::uint8_t> bytes) {
CollapsedWav out;
MonoCollapse collapse = collapseToMono(bytes);
if (collapse.collapsed) {
const WavLayout rebuilt = parseWavLayout(collapse.bytes);
if (rebuilt.valid) {
out.bytes = std::move(collapse.bytes);
out.layout = rebuilt;
out.collapsed = true;
return out;
}
}
out.bytes = std::move(bytes);
out.layout = parseWavLayout(out.bytes);
return out;
}
std::string monoCollapseSuffix(MonoCollapseOutcome outcome) {
switch (outcome) {
case MonoCollapseOutcome::Declined: return {};
+16
View File
@@ -117,6 +117,22 @@ struct MonoCollapse {
// including the bext/source-position consequence beyond hashing.
MonoCollapse collapseToMono(const std::vector<std::uint8_t>& bytes);
// A buffer after the collapse has had its say, PAIRED with the parse of the bytes
// actually returned — so a caller that hashes `bytes`, reads a channel count off
// `layout` and then writes `bytes` cannot describe one buffer while writing another.
struct CollapsedWav {
std::vector<std::uint8_t> bytes; // the rebuilt 1-channel WAV, or the input verbatim
WavLayout layout; // the parse OF `bytes`
bool collapsed = false;
};
// `collapseToMono` over a whole buffer, for a caller that goes on to hash and measure
// the result rather than rewrite a file (`shell/capture`'s collapseCapturedFileToMono is
// the file-side path over the same predicate). Takes the buffer by value: a decline hands
// those same bytes straight back. A rebuild that does not parse back is discarded rather
// than returned, so an invalid `layout` can only ever mean the INPUT was not a usable WAV.
CollapsedWav applyMonoCollapse(std::vector<std::uint8_t> bytes);
// How applying the collapse to a captured FILE ended. `Declined` is collapseToMono's own
// "nothing to do"; `Failed` is a read that never happened or a warranted rewrite that did
// not land. The capture is intact and correctly measured in every case — only the report
+15 -3
View File
@@ -3,6 +3,8 @@
#include <algorithm>
#include <unordered_set>
#include "core/util/ascii_ws.h"
// bank_book implementation — the registry RULES half: construction, pool
// privileges, bank lifecycle, active bank, sample movement/removal, slot order,
// and the reference queries. The JSON round-trip half lives in bank_book_json.cpp,
@@ -76,9 +78,8 @@ void BankBook::normalizeOrdinals() {
// one folding rule shared with bank_book_json.cpp's parse-time coalesce.
std::string BankBook::nameKey(const std::string& s) {
std::size_t b = 0, e = s.size();
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
while (b < e && isWs(s[b])) ++b;
while (e > b && isWs(s[e - 1])) --e;
while (b < e && util::isAsciiWs(s[b])) ++b;
while (e > b && util::isAsciiWs(s[e - 1])) --e;
std::string out;
out.reserve(e - b);
for (std::size_t i = b; i < e; ++i) {
@@ -117,6 +118,17 @@ bool BankBook::createBank(const std::string& id, const std::string& displayName)
return true;
}
// Runs behind displayNameTaken so the probe and the create/rename check can never
// disagree about what "already used" means. exceptId is deliberately "" — no bank can
// carry an empty id, so nothing is excluded from the scan.
std::string BankBook::uniqueDisplayName(const std::string& seed) const {
if (!displayNameTaken(seed, /*exceptId=*/std::string{})) return seed;
for (int n = 2;; ++n) {
std::string candidate = seed + " " + std::to_string(n);
if (!displayNameTaken(candidate, /*exceptId=*/std::string{})) return candidate;
}
}
bool BankBook::renameBank(const std::string& id, const std::string& displayName) {
if (id == kPoolBankId) return false; // pool is un-renamable
Bank* b = bank(id);
+11
View File
@@ -100,6 +100,17 @@ public:
// no-op success.
bool renameBank(const std::string& id, const std::string& displayName);
// The first name in the sequence `seed`, "seed 2", "seed 3", … whose fold is free
// in this book — what a caller that must not be rejected (the package import) asks
// for before createBank. First-FREE-ascending, not highest-plus-one, so it fills a
// gap ("Drums" + "Drums 3" present yields "Drums 2") and is a pure function of the
// current name set. The seed is returned verbatim when free and is NEVER re-parsed:
// a bare trailing integer cannot be told from a user's own name, so "Kit 808" would
// become "Kit 2" under a stripping rule. Terminates by pigeonhole (one of the first
// N+1 candidates is free for N banks), so it needs no cap. A blank seed comes back
// blank — what a missing name should become is the caller's policy, not the model's.
std::string uniqueDisplayName(const std::string& seed) const;
// Deletes a named bank and its member entries (files untouched — a shell/prune
// concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
// ordinals compact after; if the deleted bank was active, falls back to the pool.
+198
View File
@@ -0,0 +1,198 @@
# src/core/package — the pure RSBK bank-package codec
## Scope
The hand-rolled `RSBK` bank-package container, entirely pure (REAPER-free,
unit-tested outside the DAW): the format contract and version ladder, the JSON
manifest, and the framing/layout codec. No filesystem — the shell
(`src/shell/package`) streams bytes against the layouts produced here. The
export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
both have landed.
## Invariants
- **The container is the proprietary `RSBK` — ruled, not revisitable here.** No
ZIP, no compressor, no link edge to `vendor/WDL/WDL/zlib/`. The version
ladder, not a format swap, is how the format moves.
- **Two version integers, two jobs.** `formatVersion` = what the writer
emitted; `minReaderVersion` = the oldest reader that can read it safely. The
reader's whole rule is `minReaderVersion <= kPackageFormatVersion`. Additive
changes (a new optional manifest key, a new enum value with a defined
degrade) bump `formatVersion` only; structural changes bump both. The full
ladder lives as a comment in `package_format.h` and is READ and validated,
never merely written.
- **TooNew refuses whole.** A `minReaderVersion` above this build yields the
header (so the refusal can name the writer's semver and version) and nothing
else — no manifest, no layout, no half-success. The fields through the writer
semver are FROZEN for all future versions to keep that refusal producible.
- **Every name and path in the format is validated on encode AND decode, to
the extent stated below**, because a package can arrive from anywhere.
Three rules, all in `package_format`, whose doc comments are the itemized
authority:
- `isValidEntryName` — a payload's name is a bare file name (no separators,
no `..` component, no drive/UNC/rooted form, no control bytes, no
Windows-reserved character, no trailing dot/space, no DOS device name,
well-formed UTF-8 only). Path expression is impossible in this field.
- `sameEntryName` — two entry names differing only by ASCII case are ONE
name. Windows and macOS's default APFS would extract them onto a single
file, and a bank authored on a case-sensitive filesystem produces the pair
honestly.
- `isValidNestedSamplePath` — the nested `Sample::relativePath` IS a path by
design, and is the one field here that can express one. It refuses a `..`
component and every absolute form; `BankModel::add` checks only the latter,
so traversal would otherwise reach a future `import_plan` inside a record
the format vouched for. **Scope is traversal and absolute-form only** — no
UTF-8 well-formedness check (unlike `isValidEntryName`), no device-name
check, no case-fold dedup on `relativePath` (unlike `sameEntryName` on the
entry name). Correct for what this field is — a *record* field, not a
filesystem destination; `BankModel::add` owns the rest. Forward contract
for `import_plan`: **the destination file is derived from the entry name,
never from `relativePath`.**
- **Framing only, never a payload.** `bank_package` produces header bytes and
an ordered `{name, offset, length}` layout; it never holds, copies, or hashes
an entry's audio. `decodePackage` proves prefix + payload lengths equal the
observed file size exactly, so truncation and trailing garbage are Malformed
without any payload being read.
- **Per-sample shape has one owner.** Each entry nests a one-sample `BankModel`
blob emitted/parsed by `bank_model`'s own codec (the `bank_book_json`
precedent), so a future `Sample` field reaches packages with no change here.
- **Hostile input: error signaled, never UB** — the `bank_model.h` deserialize
standard, plus allocation caps on every length field so a forged header
cannot demand gigabytes.
## Modules
- `package_format` — the contract: magic, `kPackageFormatVersion` /
`kPackageMinReaderVersion`, the ladder comment, the three-way
`classifyPackageVersion` (`Readable` / `TooNew` / `Malformed`), the three
naming rules above, and `PackageHeader`.
- `package_manifest` — the manifest model (`PackageEntry` / `PackageManifest`)
and its JSON codec. Per entry: bare name, byte length, and a whole-file
`capture::hashBytes` digest (deliberately NOT `hashWavContent`, which skips
chunks and cannot answer "did these bytes survive") — the digest is carried
here, computed where payloads are streamed (shell). The bank's `slot_map`
rides along. Unknown keys skip at every level; duplicate entry names are
rejected both ways.
- `export_plan` — the pure export decision over value inputs (the bank's members
plus the shell's per-file probe result): the verdict (`Ready` / `Incomplete` /
`Refused`), the transport name per shipping entry, and what is excluded and why
(missing / unreadable / an index record the format cannot represent). Owns the
name repair the codec's refusal backstops, and normalizes each shipping record's
`relativePath` to the bare package name — see the transport-name gotcha below.
- `import_plan` — the pure import decision, and the reason the whole feature is
testable without a DAW: the destination bank's display name after
`BankBook`'s own fold, the reminted sample ids and remapped parents, and the
per-entry land / collapse / rename disposition. Also `importLedgerRefusal` (the
import's ledger gate, delegating entirely to `tracking::ledgerDegraded`) and
`ledgerRefusalMessage` (the gate's console-block body, a pure
`(LedgerRefusal, namespace) -> string` fold the shell only supplies the
channel-correct namespace to).
- `bank_package` — framing and arithmetic composing the four above:
`encodePackage` (prefix bytes + layout + total size, stamping this build's
ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed
file size in; header/manifest/layout out), and `requiredPrefixSize` (the
incremental-read seam for the shell). Framing rides `core/wire/bytes.h`.
`package_compat_tests` is declared here with no library of its own: it decodes the
frozen `.rsbank` corpus at `tests/fixtures/package_compat/`, whose README owns the
append-only rule and the per-fixture inventory.
## Gotchas
- Enums nested inside the `BankModel` blob follow `bank_model`'s own rule — an
out-of-range `sourceMode`/`tier` REJECTS the parse — so growing one of those
vocabularies is a `minReaderVersion` bump for packages, not an additive
change. Any enum integer the manifest itself ever adds must instead follow
the degrade-to-`Unknown` rule (`core/wire`'s `BakeStatus` precedent) to stay
additive. The manifest carries no enum of its own today.
- Sample-id rules (remap, collision, dedup across the destination) are
deliberately NOT enforced by the codec — they are `import_plan` decisions. The
codec rejects only what makes the container itself incoherent (duplicate
entry names, invalid names, a non-single-sample nested index).
- **`import_plan` consults no other bank's hashes, and that is the ruling, not
an omission.** An import always creates a NEW bank, so "already present in the
destination bank by content" is exactly "already landed by this same plan".
Cross-bank dedup is not enforced anywhere (`core/model/CLAUDE.md`), so a hash
the pool already holds still lands its own file here.
- `requiredPrefixSize` trusts fields beyond the frozen region only when the
version pair classifies `Readable`; for `TooNew` it stops at the semver —
don't "fix" it to read the manifest length there, a future structural format
may have moved it.
- A package whose header classifies `Readable` (fv > ours, minReader still
within reach — the additive case) but whose manifest fails to parse is
reported `TooNew`, not `Malformed`: the header is valid and already carries
the writer's semver, so the refusal can still name what to install. This
widens `TooNew` to cover "read and failed" as well as "stopped at the frozen
region" — both refuse whole and write nothing, so the safety property is
unchanged, only the message. `classifyPackageVersion` and the frozen-region
`TooNew` path are unaffected; this is the post-manifest-parse branch only.
- **The parse branch is the ONLY one that relabels**, deliberately: a newer
package that trips the manifest cap, a short manifest read, the layout
overflow, or the exact-size proof still reports `Malformed` even with
`formatVersion` above ours. The size proof clearly should — "install 1.9.0"
does not fix a truncated download — and the other three are indistinguishable
from ordinary corruption at the point they fail. Don't "complete" the relabel
across them for symmetry; the split is the answer, not an omission.
- The format carries no algorithm tag for `byteHash` — it is FNV-1a
(`capture::hashBytes`) implicitly. Changing the digest algorithm is a
`minReaderVersion` bump, not additive: an old reader would otherwise compare
a stored digest against bytes hashed the new way and silently misjudge
corruption.
- **A written package carries no path in ANY field.** `isValidNestedSamplePath`
permits a relative `relativePath` because a *record* may hold one, but
`export_plan` writes each shipping entry's `relativePath` as its bare, sanitized
and disambiguated transport name (`export_plan.cpp`'s `e.fileName`), so an
emitted manifest has no separator anywhere and the entry name is the single
naming authority on both sides. The directory component it drops carries no
information — the bank subfolder is a fixed `capture_paths` constant
(`capture_paths.cpp`'s `deriveBankPaths`) the importer re-spells. **The
basename spelling is dropped too, not just the directory**: `e.fileName` is
`uniqueEntryName(sanitizeEntryName(...))`, not the source basename, so a
macOS-authored `Hit?.wav` survives only in `displayName` — the transport name
itself may differ. Accepted for the same reason the directory drop is: the
transport name exists to be a valid, collision-free package entry, not a
faithful copy of the source spelling, and `displayName` is the field that
carries the original for display. The nested-path rule stays as the decode-side
backstop for a package this build did not write.
- **Obligation on the export track: sanitize, don't relay the refusal.**
`serializeManifest` returns one indistinguishable `nullopt` for every rejection
— an unrepresentable name, a case-folded collision, a traversing nested path, a
zero-length entry, a record `BankModel::add` refuses — and most of the naming
rules are Windows'. A bank ingested on macOS/Linux legitimately holds
`Hit?.wav`, `snare .wav`, or two names differing only by case, and a nested
`relativePath` is only checked for the absolute forms where it is written.
Relaying the `nullopt` makes ONE such file an unactionable total failure of the
whole export. `export_plan` must map bank entries to package
names that satisfy these rules (and disambiguate case-folded collisions) before
calling this layer; the codec's refusal is the backstop, not the user-facing
behaviour.
- **NFC/NFD normalization collisions are accepted, not solved.** macOS compares
file names normalization-insensitively, so the NFC and NFD spellings of one
accented name are two manifest entries that extract onto one file — the same
collision class as the ASCII case fold, which `sameEntryName` does catch. A
table-free fix does not exist, and restricting names to ASCII would be
genuinely over-strict for non-English users. Left open knowingly.
- **`duplicateName` folds through a hash set, not a pairwise scan.** Under the
`kMaxManifestBytes` cap (64 MB) a minimal entry is ~100 bytes, so a hostile
package can declare ~670k entries; the former double loop was ~2×10¹¹ pair
comparisons — a multi-minute hang on the decode path an import drives. The
set is keyed on `entryNameKey`, which is `sameEntryName`'s ASCII-case fold
made explicit, so the equivalence rule still has one home (`lowerAscii`).
Do not reintroduce the pairwise scan.
- **Cross-module contract with `src/shell/package`:** a genuinely zero-length
entry cannot round-trip through the filesystem seam there (`appendPayload`
refuses an empty payload — an empty buffer signals an upstream read failure,
not a real entry). `serializeManifest` refuses a zero-length `PackageEntry`
at encode so this layer never produces one; decode does not enforce it (a
hostile/older package declaring one is not this track's concern).
- **`import_plan`'s `spelledLikeABankFile` mints a fresh name even with NO
collision, and that third condition is a deliberate decision, not spec-derived.**
`docs/product/bank-package.md` §"Identity and collision on import" (collision rule 2) ties the auto-rename mint to a *collision*
only; `spelledLikeABankFile` additionally mints whenever the package's own name
isn't spelled the way `deriveBankPaths` spells one (extension, sanitized stem).
Kept for two reasons: uniform folder spelling for every landed file regardless of
origin, and — the sharper one — a hostile entry name that isn't a legal Windows
filename or carries an unexpected extension (e.g. `evil.exe`) lands sanitized
(`evil_<tag>.wav`) rather than verbatim. `ImportPlan` counts this separately from a
genuine folder-name collision (`sanitizeRenameCount` vs `collisionRenameCount`) so
the summary line means what `bank-package.md` §"Identity and collision on import" (collision rule 2) says it means.
+32
View File
@@ -0,0 +1,32 @@
reasampler_pure_library(package_format SOURCES package_format.cpp)
reasampler_test(package_format LINK package_format)
reasampler_pure_library(package_manifest
SOURCES package_manifest.cpp
LINK PUBLIC bank_model slot_map PRIVATE package_format json)
reasampler_test(package_manifest LINK package_manifest)
reasampler_pure_library(export_plan
SOURCES export_plan.cpp
LINK PUBLIC package_manifest PRIVATE package_format)
reasampler_test(export_plan LINK export_plan package_format)
# bytes.h is header-only (see src/core/wire/CLAUDE.md) — no wire link edge needed.
reasampler_pure_library(bank_package
SOURCES bank_package.cpp
LINK PUBLIC package_format package_manifest PRIVATE app_version)
# app_version: the tests pin the stamped writer semver against stampVersion().
reasampler_test(bank_package LINK bank_package app_version)
# The frozen compatibility corpus, decoded rather than regenerated. Fixture path: see
# tests/package_fixtures.h.
reasampler_test(package_compat LINK bank_package)
target_compile_definitions(package_compat_tests PRIVATE
REASAMPLER_PACKAGE_FIXTURE_DIR="${REASAMPLER_PACKAGE_FIXTURE_DIR}")
reasampler_pure_library(import_plan
SOURCES import_plan.cpp
LINK PUBLIC package_manifest bank_book origin_ledger PRIVATE package_format capture_paths)
# tracking_authority: the ledger-gate test proves the import does NOT share prune's
# composite blocker, which needs the composite to compare against.
reasampler_test(import_plan LINK import_plan tracking_authority)
+145
View File
@@ -0,0 +1,145 @@
#include "core/package/bank_package.h"
#include <cstring>
#include "core/version/app_version.h"
#include "core/wire/bytes.h"
// Byte offsets (format 1, see package_format.h's ladder): magic at 0, u32
// formatVersion at 4, u32 minReaderVersion at 8, u32 semver length W at 12,
// semver at 16, u32 manifest length M at 16+W, manifest at 20+W, payloads at
// 20+W+M. The region through the semver is the FROZEN refusal surface.
namespace reasampler::package {
namespace {
constexpr std::size_t kMagicBytes = 4;
constexpr std::size_t kSemverLenAt = 12;
constexpr std::size_t kSemverAt = 16;
bool magicMatches(const std::vector<std::uint8_t>& bytes) {
return bytes.size() >= kMagicBytes &&
std::memcmp(bytes.data(), kPackageMagic, kMagicBytes) == 0;
}
std::uint32_t u32At(const std::vector<std::uint8_t>& bytes, std::size_t at) {
std::uint32_t v = 0;
for (std::size_t b = 0; b < 4; ++b)
v |= static_cast<std::uint32_t>(bytes[at + b]) << (b * 8);
return v;
}
// Appends the payload spans for `entries` starting at `firstOffset`. False on
// u64 overflow (a forged length field summing past 2^64 must not wrap into a
// plausible layout).
bool appendSpans(const std::vector<PackageEntry>& entries, std::uint64_t firstOffset,
std::vector<PackageEntrySpan>& out, std::uint64_t& end) {
std::uint64_t offset = firstOffset;
for (const auto& e : entries) {
out.push_back({e.fileName, offset, e.byteLength});
if (offset + e.byteLength < offset) return false;
offset += e.byteLength;
}
end = offset;
return true;
}
} // namespace
std::optional<EncodedPackage> encodePackage(const PackageManifest& m) {
auto manifestJson = serializeManifest(m);
if (!manifestJson) return std::nullopt;
if (manifestJson->size() > kMaxManifestBytes) return std::nullopt;
const std::string& writer = version::stampVersion();
if (writer.size() > kMaxWriterVersionBytes) return std::nullopt;
EncodedPackage enc;
auto& out = enc.prefix;
out.insert(out.end(), kPackageMagic, kPackageMagic + kMagicBytes);
wire::putLE(out, kPackageFormatVersion);
wire::putLE(out, kPackageMinReaderVersion);
wire::putLE(out, static_cast<std::uint32_t>(writer.size()));
out.insert(out.end(), writer.begin(), writer.end());
wire::putLE(out, static_cast<std::uint32_t>(manifestJson->size()));
out.insert(out.end(), manifestJson->begin(), manifestJson->end());
if (!appendSpans(m.entries, out.size(), enc.layout, enc.totalSize))
return std::nullopt;
return enc;
}
DecodedPackage decodePackage(const std::vector<std::uint8_t>& prefix,
std::uint64_t totalFileSize) {
DecodedPackage dec; // status starts Malformed; every early return means it
wire::ByteReader r(prefix);
if (r.str(kMagicBytes) != std::string(kPackageMagic, kMagicBytes)) return dec;
const std::uint32_t formatVersion = r.u32();
const std::uint32_t minReader = r.u32();
if (!r.ok) return dec;
const PackageReadability verdict = classifyPackageVersion(formatVersion, minReader);
if (verdict == PackageReadability::Malformed) return dec;
const std::uint32_t semverLen = r.u32();
if (!r.ok || semverLen > kMaxWriterVersionBytes) return dec;
std::string writer = r.str(semverLen);
if (!r.ok) return dec;
dec.header = PackageHeader{formatVersion, minReader, std::move(writer)};
if (verdict == PackageReadability::TooNew) {
// Refuse whole: the header names the writer for the message; nothing
// past the frozen region is read, and no manifest is produced.
dec.status = PackageReadability::TooNew;
return dec;
}
const std::uint32_t manifestLen = r.u32();
if (!r.ok || manifestLen > kMaxManifestBytes) return dec;
const std::string manifestJson = r.str(manifestLen);
if (!r.ok) return dec;
auto manifest = deserializeManifest(manifestJson);
if (!manifest) {
// A newer additive format's parse failure reports TooNew, not the
// unactionable Malformed — the header (with the writer semver) is
// already valid here. See this directory's CLAUDE.md for the tradeoff.
if (formatVersion > kPackageFormatVersion) dec.status = PackageReadability::TooNew;
return dec;
}
std::uint64_t end = 0;
std::vector<PackageEntrySpan> layout;
if (!appendSpans(manifest->entries, r.pos, layout, end)) return dec;
// Exact-size proof: a byte missing (truncation) or a byte extra (trailing
// garbage) both fail, even though no payload is read here.
if (end != totalFileSize) return dec;
dec.status = PackageReadability::Readable;
dec.manifest = std::move(*manifest);
dec.layout = std::move(layout);
dec.prefixSize = r.pos;
return dec;
}
std::optional<std::uint64_t> requiredPrefixSize(const std::vector<std::uint8_t>& bytes) {
if (bytes.size() < kSemverAt) return kSemverAt;
if (!magicMatches(bytes)) return std::nullopt;
const auto verdict = classifyPackageVersion(u32At(bytes, 4), u32At(bytes, 8));
if (verdict == PackageReadability::Malformed) return std::nullopt;
const std::uint32_t semverLen = u32At(bytes, kSemverLenAt);
if (semverLen > kMaxWriterVersionBytes) return std::nullopt;
const std::uint64_t throughSemver = kSemverAt + semverLen;
if (verdict == PackageReadability::TooNew) return throughSemver;
if (bytes.size() < throughSemver + 4) return throughSemver + 4;
const std::uint32_t manifestLen = u32At(bytes, static_cast<std::size_t>(throughSemver));
if (manifestLen > kMaxManifestBytes) return std::nullopt;
return throughSemver + 4 + manifestLen;
}
} // namespace reasampler::package
+72
View File
@@ -0,0 +1,72 @@
#pragma once
// bank_package — RSBK framing and layout arithmetic: header encode, prefix
// decode, and the ordered {name, offset, length} entry layout. See this
// directory's CLAUDE.md for the framing-only invariant. Pure: no filesystem.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/package/package_format.h"
#include "core/package/package_manifest.h"
namespace reasampler::package {
// Where one payload sits in the finished package file (absolute byte offset).
struct PackageEntrySpan {
std::string name;
std::uint64_t offset = 0;
std::uint64_t length = 0;
bool operator==(const PackageEntrySpan& o) const {
return name == o.name && offset == o.offset && length == o.length;
}
};
// The write side's product: the prefix bytes (magic through manifest, written
// verbatim as the file's head), the layout to stream each payload at, and the
// finished file's exact size — what the shell verifies after the last append.
struct EncodedPackage {
std::vector<std::uint8_t> prefix;
std::vector<PackageEntrySpan> layout;
std::uint64_t totalSize = 0;
};
// Encodes the package prefix for `m`, stamping this build's version pair and
// version::stampVersion() as the writer semver. nullopt when the manifest
// cannot be represented (serializeManifest's rejections) — refused on encode so
// an undecodable package is never written.
std::optional<EncodedPackage> encodePackage(const PackageManifest& m);
// The read side's product. header is meaningful for Readable and TooNew (a
// refusal must still name the writer), and on any Malformed reached after the
// header parsed (a corrupt manifest at this build's own version carries the
// real pair, not the 0/0 unparsed default); manifest, layout, and prefixSize
// only for Readable — TooNew produces NO manifest, so a refused decode cannot
// half-succeed.
struct DecodedPackage {
PackageReadability status = PackageReadability::Malformed;
PackageHeader header;
PackageManifest manifest;
std::vector<PackageEntrySpan> layout;
std::uint64_t prefixSize = 0;
};
// Decodes a package's leading bytes. `totalFileSize` is the on-disk size the
// caller observed: decode proves prefix + payload lengths equal it exactly, so
// a truncated or garbage-extended file is Malformed even though the payloads
// themselves are never read here. `prefix` may be the whole file or any head of
// it that requiredPrefixSize accepted. Error signaled, never UB.
DecodedPackage decodePackage(const std::vector<std::uint8_t>& prefix,
std::uint64_t totalFileSize);
// How many leading bytes decodePackage needs. May grow as bytes arrive: with
// fewer than the returned count on hand, read to that count and ask again.
// For a TooNew package it stops at the frozen region (through the writer
// semver) — field positions beyond it belong to the newer format and are not
// trusted. nullopt: these bytes can never frame a package (bad magic,
// incoherent versions, an over-cap length field) — stop reading.
std::optional<std::uint64_t> requiredPrefixSize(const std::vector<std::uint8_t>& bytes);
} // namespace reasampler::package
+177
View File
@@ -0,0 +1,177 @@
// export_plan.cpp — see export_plan.h for the contract.
#include "core/package/export_plan.h"
#include <cstddef>
#include "core/package/package_format.h"
namespace reasampler::package {
namespace {
// The bare file name a bank-relative path ends in. Separators are matched in both
// spellings: a persisted index may hold either on Windows.
std::string baseNameOf(const std::string& path) {
const std::size_t sep = path.find_last_of("/\\");
return sep == std::string::npos ? path : path.substr(sep + 1);
}
// Mirrors package_format.cpp's trailing-dot/space rule so a truncated stem never
// reintroduces the collision isValidEntryName exists to prevent.
std::string stripTrailingDotsAndSpaces(std::string s) {
while (!s.empty() && (s.back() == '.' || s.back() == ' ')) s.pop_back();
return s;
}
// Truncates to at most `max` bytes without splitting a UTF-8 sequence (the rule
// itself is package_format.cpp's isWellFormedUtf8). A sequence landing exactly on
// the cut is dropped whole, one character short of `max`, rather than checked for
// cleanliness — over-truncating by one character is cheap insurance against a
// subtly wrong boundary check.
std::string truncateUtf8(std::string s, std::size_t max) {
if (s.size() <= max) return s;
s.resize(max);
while (!s.empty() && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
if (!s.empty() && static_cast<unsigned char>(s.back()) >= 0xC0) s.pop_back();
return s;
}
// `name` with `suffix` inserted before its extension, trimmed so the result still
// fits the entry-name cap. `suffix.size() + ext.size()` can exceed the cap on its
// own (a long extension, a two-digit disambiguation suffix) — clamped rather than
// subtracted unchecked, which would underflow the size_t `room` below and turn
// truncateUtf8 into a silent no-op. An extension that alone leaves no room even
// after the whole stem is dropped is dropped too; uniqueEntryName's own floor
// covers what even that cannot fix.
std::string insertSuffix(const std::string& name, const std::string& suffix) {
const std::size_t dot = name.find_last_of('.');
const bool hasExt = dot != std::string::npos && dot > 0;
std::string stem = hasExt ? name.substr(0, dot) : name;
std::string ext = hasExt ? name.substr(dot) : std::string();
if (suffix.size() >= kMaxEntryNameBytes) return std::string();
if (ext.size() > kMaxEntryNameBytes - suffix.size()) ext.clear();
const std::size_t room = kMaxEntryNameBytes - suffix.size() - ext.size();
stem = truncateUtf8(std::move(stem), room);
return stem + suffix + ext;
}
bool nameTaken(const std::string& candidate, const std::vector<std::string>& taken) {
for (const std::string& t : taken)
if (sameEntryName(candidate, t)) return true;
return false;
}
// A transport name distinct from every name already claimed, under the format's own
// case-folding equivalence (package_format.h's sameEntryName).
std::string uniqueEntryName(const std::string& base, const std::vector<std::string>& taken) {
if (!nameTaken(base, taken)) return base;
// Each iteration either returns a name both valid and distinct from `taken`, or
// advances to the next suffix; taken.size() + 2 attempts is enough by pigeonhole
// now that insertSuffix cannot underflow. The floor below is the residual case
// validity alone can still fail — an extension so long insertSuffix must drop it
// on every attempt tried here.
for (std::size_t n = 2; n <= taken.size() + 2; ++n) {
const std::string candidate = insertSuffix(base, "_" + std::to_string(n));
if (!nameTaken(candidate, taken) && isValidEntryName(candidate)) return candidate;
}
// Floors like sanitizeEntryName's own "entry" floor: always valid, regardless of
// how base's own extension behaved.
return sanitizeEntryName("entry_" + std::to_string(taken.size() + 2));
}
// What BankModel::add and the manifest's nested-path rule together accept — the pair
// package_manifest::serializeManifest checks per entry. The codec's refusal is the
// backstop; classifying here is what lets the export name the offending entry.
bool recordRepresentable(const model::Sample& s) {
return !s.id.empty() && isValidNestedSamplePath(s.relativePath);
}
ExcludedEntry excludedFrom(const model::Sample& s, ExclusionReason reason) {
ExcludedEntry e;
e.sampleId = s.id;
e.displayName = s.displayName;
e.relativePath = s.relativePath;
e.reason = reason;
return e;
}
} // namespace
std::string sanitizeEntryName(const std::string& rawFileName) {
std::string n = rawFileName;
for (char& c : n) {
const unsigned char u = static_cast<unsigned char>(c);
if (u < 0x20 || u == 0x7F || u == '/' || u == '\\' || u == ':' || u == '*' ||
u == '?' || u == '|' || u == '<' || u == '>' || u == '"')
c = '_';
}
// One byte of headroom so the prefix repair below still fits the cap.
n = stripTrailingDotsAndSpaces(truncateUtf8(std::move(n), kMaxEntryNameBytes - 1));
if (isValidEntryName(n)) return n;
// One prefix answers every remaining reserved form at once: "." / "..", a DOS
// device name, and a name the strips emptied.
std::string prefixed = stripTrailingDotsAndSpaces("_" + n);
if (isValidEntryName(prefixed)) return prefixed;
// Ill-formed UTF-8 is what is left, and isValidEntryName is the only authority on
// it here, so fold the whole non-ASCII range rather than re-deriving the scanner.
for (char& c : prefixed)
if (static_cast<unsigned char>(c) >= 0x80) c = '_';
prefixed = stripTrailingDotsAndSpaces(prefixed);
return isValidEntryName(prefixed) ? prefixed : std::string("entry");
}
ExportPlan planExport(const ExportInputs& in) {
ExportPlan plan;
plan.manifest.bankDisplayName = in.bankDisplayName;
bool anyAbsent = false;
bool anyUnrepresentable = false;
std::vector<std::string> takenNames;
std::vector<std::string> shippedIds;
for (const ExportCandidate& c : in.candidates) {
if (!recordRepresentable(c.sample)) {
plan.excluded.push_back(
excludedFrom(c.sample, ExclusionReason::RecordUnrepresentable));
anyUnrepresentable = true;
continue;
}
if (c.fileState != SourceFileState::Present) {
plan.excluded.push_back(excludedFrom(
c.sample, c.fileState == SourceFileState::Unreadable
? ExclusionReason::FileUnreadable
: ExclusionReason::FileMissing));
anyAbsent = true;
continue;
}
PackageEntry e;
e.sample = c.sample;
e.fileName = uniqueEntryName(sanitizeEntryName(baseNameOf(c.sample.relativePath)),
takenNames);
// The transport record names its payload by the package name and nothing
// else, so the manifest carries no path at all — the bank subfolder is a
// fixed constant the importer re-spells through capture_paths.
e.sample.relativePath = e.fileName;
takenNames.push_back(e.fileName);
shippedIds.push_back(c.sample.id);
plan.sourceRelativePaths.push_back(c.sample.relativePath);
plan.manifest.entries.push_back(std::move(e));
}
// Display positions follow membership: an excluded entry's slot marker would name
// a sample the package does not carry.
plan.manifest.slots = in.slots;
plan.manifest.slots.reconcile(shippedIds);
plan.verdict = anyUnrepresentable ? ExportVerdict::Refused
: anyAbsent ? ExportVerdict::Incomplete
: ExportVerdict::Ready;
return plan;
}
} // namespace reasampler::package
+87
View File
@@ -0,0 +1,87 @@
#pragma once
// export_plan — the pure export decision: which bank entries ship, what each one is
// named inside the package, what is absent, and therefore whether the export may
// proceed at all. Values in, verdict out — the shell probes the filesystem and hands
// the results here. Pure: no filesystem, no host types.
#include <string>
#include <vector>
#include "core/model/bank_model.h"
#include "core/model/slot_map.h"
#include "core/package/package_manifest.h"
namespace reasampler::package {
// What the shell's filesystem probe found for one indexed entry. Missing and
// Unreadable stay distinct all the way to the refusal message: the file is gone vs.
// the file is there and will not open, which have opposite recoveries.
enum class SourceFileState {
Present,
Missing,
Unreadable,
};
struct ExportCandidate {
model::Sample sample;
SourceFileState fileState = SourceFileState::Missing;
};
// One bank as the planner sees it: the display name that rides in the manifest
// envelope, the members in bank insertion order, and the bank's display positions.
struct ExportInputs {
std::string bankDisplayName;
std::vector<ExportCandidate> candidates;
model::SlotMap slots;
};
// Why an indexed entry cannot ship.
enum class ExclusionReason {
FileMissing,
FileUnreadable,
// The index record itself cannot be written: an empty id, or a relativePath the
// format's nested-path rule refuses. Not something a confirm can proceed past.
RecordUnrepresentable,
};
struct ExcludedEntry {
std::string sampleId;
std::string displayName;
std::string relativePath;
ExclusionReason reason = ExclusionReason::FileMissing;
};
enum class ExportVerdict {
Ready, // every candidate ships
Incomplete, // a file is absent or unreadable; the rest may ship behind an explicit confirm
Refused, // an index record the format cannot represent — no confirm path
};
struct ExportPlan {
ExportVerdict verdict = ExportVerdict::Ready;
// Entries in bank order, each carrying its transport name and its record. The
// shell measures `byteLength`/`byteHash` from the payload, so they are 0/"" here;
// `exportTimestamp` is the shell's clock read and is 0 here too.
PackageManifest manifest;
// Where each shipping entry's bytes are read from, parallel to
// `manifest.entries` — the record's own relativePath is normalized to the bare
// package name (see the transport-name note in this directory's CLAUDE.md), so
// the source spelling has to survive separately.
std::vector<std::string> sourceRelativePaths;
std::vector<ExcludedEntry> excluded;
};
ExportPlan planExport(const ExportInputs& in);
// The smallest repair of one bare file name that satisfies isValidEntryName —
// separators, reserved characters and control bytes to '_', an over-long name
// truncated on a UTF-8 boundary, and an underscore prefix for the reserved forms
// ("." / ".." / a DOS device name). Never returns a name isValidEntryName refuses.
// Why sanitize rather than relay the codec's refusal: this directory's own
// CLAUDE.md, "Obligation on the export track."
std::string sanitizeEntryName(const std::string& rawFileName);
} // namespace reasampler::package
+221
View File
@@ -0,0 +1,221 @@
#include "core/package/import_plan.h"
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include "core/capture/capture_paths.h"
#include "core/package/package_format.h"
#include "core/util/ascii_ws.h"
namespace reasampler::package {
namespace {
using capture::bankRelativeForName;
using capture::deriveBankPaths;
using capture::sanitizeStem;
using util::isAsciiWs;
// Shares BankBook::nameKey's whitespace set (core/util/ascii_ws.h) so a name nameKey
// would fold to empty is never treated as recorded here.
bool blankName(const std::string& s) {
for (char c : s)
if (!isAsciiWs(c)) return false;
return true;
}
// "kick.wav" -> "kick"; a name with no dot is its own stem. deriveBankPaths re-adds
// the extension, so handing it the full name would file "kick.wav" as "kick.wav.wav".
std::string stemOf(const std::string& fileName) {
const std::size_t dot = fileName.rfind('.');
if (dot == std::string::npos || dot == 0) return fileName;
return fileName.substr(0, dot);
}
// True when `fileName` is already spelled the way this tool spells a bank file, so a
// package landing in a fresh project keeps the names it travelled with. Anything else
// is minted through deriveBankPaths, which is also the sanitizer.
bool spelledLikeABankFile(const std::string& fileName) {
const std::string stem = stemOf(fileName);
return stem != fileName && sanitizeStem(stem) == stem && fileName == stem + ".wav";
}
// The bank-folder names an import must not land on: what is there already, plus what
// this import has minted so far. Case-folded, because the two filesystems this tool
// ships on would treat "Kick.wav" and "kick.wav" as one file.
//
// `bankFolderFileNames` comes from `listFolderFileNames` (shell/package/package_io),
// which skips non-regular files — so a DIRECTORY sharing a bank file's name is
// invisible here. The plan then never mints around it, and the later exclusive-create
// land fails on that one entry (WriteFailed, rolled back). Safe direction ("never
// overwrite" still holds) but worth knowing before chasing a WriteFailed report that
// traces back to a same-named folder in the bank directory; test_import_landing's
// rollback suite deliberately exploits this to exercise the rollback path.
class NameSet {
public:
explicit NameSet(const std::vector<std::string>& present) {
keys_.reserve(present.size());
for (const std::string& n : present) keys_.insert(entryNameKey(n));
}
bool taken(const std::string& name) const { return keys_.count(entryNameKey(name)) != 0; }
void claim(const std::string& name) { keys_.insert(entryNameKey(name)); }
private:
std::unordered_set<std::string> keys_;
};
// The name this entry lands under. Terminates: each attempt carries a distinct
// counter, and the taken set is finite.
std::string mintFileName(const std::string& projectDir, const std::string& packageName,
const std::string& uniqueTag, const NameSet& taken) {
if (spelledLikeABankFile(packageName) && !taken.taken(packageName)) return packageName;
const std::string stem = stemOf(packageName);
std::string tag = uniqueTag;
for (int n = 2;; ++n) {
const std::string candidate = deriveBankPaths(projectDir, stem, tag).fileName;
if (!taken.taken(candidate)) return candidate;
tag = uniqueTag + "-" + std::to_string(n);
}
}
} // namespace
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status) {
// Delegates the refuse/proceed decision entirely to ledgerDegraded() rather than
// re-deriving it from the two named statuses, so a future degraded status added
// there is refused here too rather than silently falling through to None.
if (!tracking::ledgerDegraded(status)) return LedgerRefusal::None;
// Below this point status is known degraded; only the message variant is picked.
// Unreadable gets its own "corrupt, may be cleared" wording; every other degraded
// status (today only FutureVersion) gets the "written by a newer build" wording.
return status == tracking::LedgerStatus::Unreadable ? LedgerRefusal::Malformed
: LedgerRefusal::FutureVersion;
}
// Mirrors prune's abort block in structure and tone (shell/actions/prune_action.cpp),
// because a user who has hit that one should recognise this one. Every recovery line
// names THIS build's namespace: a beta user handed the stable spelling clears the wrong
// key and is still blocked.
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace) {
if (refusal == LedgerRefusal::None) return {};
std::string msg =
"ReaSampler import: ABORTED -- the file-tracking ledger could not be read. "
"Nothing was imported.\n";
if (refusal == LedgerRefusal::Malformed) {
msg += "The stored file-tracking ledger is malformed. It has been left intact "
"rather than overwritten, so it can be repaired or cleared:\n"
" reaper.SetProjExtState(0, \"" + extStateNamespace + "\", \"owned_files\", \"\")\n"
"Clearing it makes every existing bank file un-reclaimable (they stop "
"being attributable to ReaSampler); no file is lost. Reopen the project "
"afterwards -- the block is held for the rest of this session.\n";
} else {
msg += "The stored file-tracking ledger was written by a NEWER version of "
"ReaSampler than this one, so its records cannot be read safely. It has "
"been left intact and will NOT be overwritten. Reopen the project with "
"that newer version -- do NOT clear this key from here, that would "
"discard tracking records this build cannot see. The block is held for "
"the rest of this session.\n";
}
msg += "An import can land hundreds of files in one gesture. With no readable "
"ledger, none of them could be given a birth record, and every one would be "
"permanently unreclaimable.\n";
return msg;
}
std::string bankFolderDir(const std::string& projectDir) {
// Only the directory half of the result is wanted; the stem is a placeholder.
return deriveBankPaths(projectDir, "bank", std::string{}).absoluteDir;
}
ImportPlan planImport(const PackageManifest& manifest,
const BankBook& destination,
const std::string& projectDir,
const std::vector<std::string>& bankFolderFileNames,
const std::string& uniqueTag) {
ImportPlan plan;
plan.seedBankName = blankName(manifest.bankDisplayName)
? std::string(kDefaultImportBankName)
: manifest.bankDisplayName;
plan.bankDisplayName = destination.uniqueDisplayName(plan.seedBankName);
plan.bankNameAdjusted = plan.bankDisplayName != plan.seedBankName;
NameSet taken(bankFolderFileNames);
// The destination bank is created empty by this same import, so "already in the
// destination bank by content" is exactly "already landed by this plan" — the
// hash set below IS that bank's findByHash. Cross-bank dedup is deliberately not
// enforced (core/model/CLAUDE.md), so other banks' hashes are not consulted.
std::unordered_map<std::string, std::string> landedIdForHash;
// Every package id, including a collapsed one's, so a parent link that pointed at
// a duplicate still resolves to the entry that survived it.
std::unordered_map<std::string, std::string> idRemap;
plan.entries.reserve(manifest.entries.size());
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
const PackageEntry& src = manifest.entries[i];
PlannedEntry e;
e.manifestIndex = i;
const std::string& hash = src.sample.contentHash;
if (!hash.empty()) {
const auto hit = landedIdForHash.find(hash);
if (hit != landedIdForHash.end()) {
e.action = EntryAction::Collapse;
idRemap[src.sample.id] = hit->second;
++plan.collapseCount;
plan.entries.push_back(std::move(e));
continue;
}
}
e.destFileName = mintFileName(projectDir, src.fileName, uniqueTag, taken);
e.renamed = e.destFileName != src.fileName;
taken.claim(e.destFileName);
e.sample = src.sample;
e.sample.id = std::string(kImportIdPrefix) + uniqueTag + "-" + e.destFileName;
e.sample.relativePath = bankRelativeForName(e.destFileName);
if (!hash.empty()) landedIdForHash.emplace(hash, e.sample.id);
idRemap[src.sample.id] = e.sample.id;
++plan.landCount;
// A rename happens for one of two reasons: the package's own name was already
// taken (spelledLikeABankFile true but the mint's fast path lost the race to
// `taken`), or the name never qualified for that fast path at all (sanitize).
if (e.renamed) {
if (spelledLikeABankFile(src.fileName)) ++plan.collisionRenameCount;
else ++plan.sanitizeRenameCount;
}
plan.entries.push_back(std::move(e));
}
// Second pass: the remap must be complete before a parent is resolved, since a
// sample may precede its own parent in manifest order.
for (PlannedEntry& e : plan.entries) {
if (e.action != EntryAction::Land || !e.sample.provenance) continue;
const auto hit = idRemap.find(e.sample.provenance->parentSampleId);
e.sample.provenance->parentSampleId =
hit == idRemap.end() ? std::string{} : hit->second;
}
// The package's display order, over the ids that actually landed. SlotMap's own
// repair rules settle the rest: two package ids collapsed onto one landed id give
// one slot (first wins), and a landed sample the package never positioned is
// appended by BankBook::reconcileSlots afterwards.
std::vector<std::pair<std::string, int>> slotPairs;
for (const std::string& oldId : manifest.slots.orderedIds()) {
const auto hit = idRemap.find(oldId);
if (hit == idRemap.end()) continue;
slotPairs.emplace_back(hit->second, manifest.slots.slotOf(oldId));
}
plan.slots = model::SlotMap::fromEntries(slotPairs);
return plan;
}
} // namespace reasampler::package
+88
View File
@@ -0,0 +1,88 @@
#pragma once
// import_plan — the pure import decision: the destination bank's display name after
// the book's own uniqueness fold, the reminted sample ids and remapped parents, and
// the per-entry land / collapse / rename disposition. Value inputs only; no
// filesystem, no session handle, no host types.
#include <cstddef>
#include <string>
#include <vector>
#include "core/model/bank_book.h"
#include "core/model/slot_map.h"
#include "core/package/package_manifest.h"
#include "core/tracking/origin_ledger.h"
namespace reasampler::package {
// The bank name a package that recorded none (or a blank one) imports under.
inline constexpr const char* kDefaultImportBankName = "Imported bank";
// The prefix every imported sample id is reminted under, so a package's own ids —
// unique only within the project that made them — never enter this index.
inline constexpr const char* kImportIdPrefix = "pkg-";
// Which of the two refusal messages the import owes the user, if either.
//
// Keyed on the LEDGER STATUS ALONE, never on prune's composite blockedByTracking:
// that flag also fires on undecodable rsusage_* keys, which govern which files a
// DELETION may touch. An import deletes nothing and computes no protected set — it
// writes birth records — so an unreadable usage key must not refuse one.
enum class LedgerRefusal { None, Malformed, FutureVersion };
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status);
// The console-block body for a refusal — a pure (LedgerRefusal, namespace) -> string
// fold, so the wording is assertable without a DAW. `extStateNamespace` is the
// channel-correct namespace (`version::extStateNamespace()`) every recovery line must
// name, so a beta user is never handed the stable spelling. Empty string for None —
// callers only reach this once `importLedgerRefusal` has already returned a refusal.
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace);
// What one manifest entry does when the import runs.
// - Land: write the payload under destFileName and add `sample`.
// - Collapse: an equal contentHash already lands in this same import, so the payload
// is NOT written and no entry is added. Writing it and letting
// BankModel::add collapse the entry would leave the file referenced by
// nothing — an orphan manufactured by a dedup.
enum class EntryAction { Land, Collapse };
struct PlannedEntry {
std::size_t manifestIndex = 0;
EntryAction action = EntryAction::Land;
std::string destFileName; // Land only — a bare name in the bank folder
model::Sample sample; // Land only — id, path and parent already remapped
bool renamed = false; // the package's own name was taken, so a fresh one was minted
};
struct ImportPlan {
std::string bankDisplayName;
bool bankNameAdjusted = false; // the seed was taken, so the name carries a suffix
std::string seedBankName; // the seed the probe started from
std::vector<PlannedEntry> entries; // one per manifest entry, in manifest order
model::SlotMap slots; // the package's slots over the reminted ids
int landCount = 0;
int collapseCount = 0;
// Two distinct triggers, counted separately (bank-package.md:447 defines the first
// as THE collision counter; conflating the second into it would misreport a mint
// that never collided as a collision).
int collisionRenameCount = 0; // the package's own name was already taken in the bank folder
int sanitizeRenameCount = 0; // the package's name was not spelled the way this tool spells
// a bank file (see spelledLikeABankFile, core/package/CLAUDE.md)
};
// The bank folder an import lands into — the same expression capture uses, so an
// imported file is spelled exactly like a captured one.
std::string bankFolderDir(const std::string& projectDir);
// Decides everything about an import except the bytes. `bankFolderFileNames` are the
// bare names already present in that folder (never overwritten); `uniqueTag` is the
// shell's per-import disambiguator, extended with an ascending counter where one tag
// is not enough. Total: every manifest entry yields exactly one PlannedEntry.
ImportPlan planImport(const PackageManifest& manifest,
const BankBook& destination,
const std::string& projectDir,
const std::vector<std::string>& bankFolderFileNames,
const std::string& uniqueTag);
} // namespace reasampler::package
+126
View File
@@ -0,0 +1,126 @@
#include "core/package/package_format.h"
#include <cstddef>
#include "core/util/relative_path.h"
namespace reasampler::package {
PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
std::uint32_t minReaderVersion) {
if (formatVersion == 0 || minReaderVersion == 0) return PackageReadability::Malformed;
if (minReaderVersion > formatVersion) return PackageReadability::Malformed;
if (minReaderVersion > kPackageFormatVersion) return PackageReadability::TooNew;
return PackageReadability::Readable;
}
namespace {
// Hand-rolled rather than std::tolower: that fold is locale-dependent, so two
// machines reading the same package could disagree on which names collide.
char lowerAscii(unsigned char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : static_cast<char>(c);
}
// Windows device names claim the whole entry regardless of extension
// (CON, CON.wav, con.WAV are all the same reserved device) — checked against
// the portion before the first dot only. The trailing three pairs are the UTF-8
// spellings of COM¹/COM²/COM³/LPT¹/LPT²/LPT³: Windows reads those ISO 8859-1
// superscripts as digits in a device name. COM0/LPT0 are NOT reserved.
bool isDosDeviceName(const std::string& name) {
std::string base = name.substr(0, name.find('.'));
for (char& c : base) c = lowerAscii(static_cast<unsigned char>(c));
static const std::string kReserved[] = {
"con", "prn", "aux", "nul",
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
"com\xC2\xB9", "com\xC2\xB2", "com\xC2\xB3",
"lpt\xC2\xB9", "lpt\xC2\xB2", "lpt\xC2\xB3",
};
for (const auto& r : kReserved) if (base == r) return true;
return false;
}
// Table-free UTF-8 well-formedness. Overlong encodings, surrogate halves and
// code points past U+10FFFF are rejected as hard as a structural length error:
// the UTF-8 -> UTF-16 conversion a host must perform maps an ill-formed
// sequence to U+FFFD unless it opts into failing, so two names differing only
// in invalid bytes would otherwise collapse onto one destination file.
bool isWellFormedUtf8(const std::string& s) {
const auto* p = reinterpret_cast<const unsigned char*>(s.data());
const std::size_t n = s.size();
for (std::size_t i = 0; i < n;) {
const unsigned char c = p[i];
std::size_t extra = 0;
std::uint32_t cp = 0;
if (c < 0x80) { ++i; continue; }
else if ((c & 0xE0) == 0xC0) { extra = 1; cp = c & 0x1Fu; }
else if ((c & 0xF0) == 0xE0) { extra = 2; cp = c & 0x0Fu; }
else if ((c & 0xF8) == 0xF0) { extra = 3; cp = c & 0x07u; }
else return false; // a stray continuation byte, or a 5/6-byte lead
if (i + extra >= n) return false;
for (std::size_t k = 1; k <= extra; ++k) {
const unsigned char cont = p[i + k];
if ((cont & 0xC0) != 0x80) return false;
cp = (cp << 6) | (cont & 0x3Fu);
}
if (extra == 1 && cp < 0x80) return false;
if (extra == 2 && cp < 0x800) return false;
if (extra == 3 && cp < 0x10000) return false;
if (cp > 0x10FFFF) return false;
if (cp >= 0xD800 && cp <= 0xDFFF) return false;
i += extra + 1;
}
return true;
}
} // namespace
bool isValidEntryName(const std::string& name) {
if (name.empty() || name.size() > kMaxEntryNameBytes) return false;
if (name == "." || name == "..") return false;
// fopen/CreateFileW both silently strip a trailing dot or space at
// creation, so "a.wav " and "a.wav" would collide on one file.
if (name.back() == '.' || name.back() == ' ') return false;
for (unsigned char c : name) {
// NUL and other control bytes truncate at the first filesystem call
// (std::ofstream, fopen, CreateFileW off .c_str()) — two names that
// differ only after the NUL land on the same file.
if (c < 0x20 || c == 0x7F) return false;
if (c == '/' || c == '\\' || c == ':') return false;
if (c == '*' || c == '?' || c == '|' || c == '<' || c == '>' || c == '"') return false;
}
if (isDosDeviceName(name)) return false;
return isWellFormedUtf8(name);
}
bool sameEntryName(const std::string& a, const std::string& b) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i)
if (lowerAscii(static_cast<unsigned char>(a[i])) !=
lowerAscii(static_cast<unsigned char>(b[i])))
return false;
return true;
}
std::string entryNameKey(const std::string& name) {
std::string key;
key.reserve(name.size());
for (unsigned char c : name) key += lowerAscii(c);
return key;
}
bool isValidNestedSamplePath(const std::string& path) {
if (util::isAbsolutePath(path)) return false;
// Component-wise, not a substring scan: "take..final/a.wav" is a legal
// relative path, "bank/../evil.wav" is not.
for (std::size_t start = 0;; ) {
const std::size_t sep = path.find_first_of("/\\", start);
const std::size_t end = (sep == std::string::npos) ? path.size() : sep;
if (path.compare(start, end - start, "..") == 0) return false;
if (sep == std::string::npos) return true;
start = sep + 1;
}
}
} // namespace reasampler::package
+110
View File
@@ -0,0 +1,110 @@
#pragma once
// package_format — the RSBK bank-package contract: magic, the version ladder,
// the readability classification, and the three naming rules below. Pure:
// standard library only. The framing codec that acts on this contract is
// bank_package; the manifest grammar is package_manifest.
#include <cstdint>
#include <string>
namespace reasampler::package {
// Version ladder for the RSBK container (read-and-validate, like the origin
// ledger's "v"):
//
// format 1 (current) magic "RSBK" | u32 formatVersion | u32 minReaderVersion
// | u32 len + writer semver | u32 len + JSON manifest
// | payloads concatenated in manifest entry order.
// All integers little-endian.
//
// Two integers, two jobs: formatVersion is what the writer emitted (monotonic,
// bumped on ANY change); minReaderVersion is the oldest reader that can read the
// package safely (bumped only on a STRUCTURAL change — a field's meaning shifts,
// a section is removed, framing moves; an additive change — a new optional
// manifest key, a new enum value with a defined degrade — leaves it alone). The
// reader's whole rule: read iff minReaderVersion <= kPackageFormatVersion.
// formatVersion beyond that is message text and log material only.
//
// FROZEN FOR ALL FUTURE VERSIONS: the fields through the writer semver. A
// too-new package must still yield the writer's version so the refusal can name
// what to install — a structural change may rearrange anything after the semver,
// never before it.
inline constexpr char kPackageMagic[4] = {'R', 'S', 'B', 'K'};
inline constexpr std::uint32_t kPackageFormatVersion = 1;
inline constexpr std::uint32_t kPackageMinReaderVersion = 1;
// Hostile-input allocation caps (error signaled, never a multi-gigabyte
// allocation off a forged length field). Generous against real content: a
// semver is ~10 bytes; a manifest for hundreds of samples is well under 1 MB.
inline constexpr std::uint32_t kMaxWriterVersionBytes = 64;
inline constexpr std::uint32_t kMaxManifestBytes = 64u * 1024u * 1024u;
inline constexpr std::size_t kMaxEntryNameBytes = 255;
// The three-way read verdict (the FutureVersion precedent): TooNew refuses the
// whole package before anything is produced; Malformed is a header no honest
// writer emits. Also the status of a full prefix decode in bank_package.
enum class PackageReadability {
Readable,
TooNew,
Malformed,
};
// Classify a stored header pair against THIS build's ladder. minReaderVersion
// above kPackageFormatVersion is TooNew; a zero version or minReader >
// formatVersion is Malformed (a writer cannot require a reader newer than what
// it wrote).
PackageReadability classifyPackageVersion(std::uint32_t formatVersion,
std::uint32_t minReaderVersion);
// The entry-name rule: a bare file name only. Rejects empty, ".", the exact
// ".." component (a name can only ever be one component, since separators are
// banned below — a substring scan would over-reject legal names like
// "take..final.wav"), any control byte (NUL included — truncates at the first
// filesystem call and collides two distinct manifest entries onto one file) or
// 0x7F, any '/', '\\' or ':' (which also bans every absolute form — drive, UNC,
// rooted), any Windows-reserved character (`*?|<>"`), a trailing dot or space
// (silently stripped at file creation, so "a.wav " and "a.wav" would collide),
// a DOS device name (CON/PRN/AUX/NUL/COM1-9/LPT1-9 plus the superscript
// COM/LPT 1-3 forms, case-insensitive, with or without an extension), names
// over kMaxEntryNameBytes, and any byte sequence that is not well-formed UTF-8.
bool isValidEntryName(const std::string& name);
// The format's name-equivalence rule: two entry names that differ only by ASCII
// case are ONE name. Windows and macOS's default APFS are case-insensitive, so
// "Kick.wav" and "kick.wav" would extract onto a single file — and a bank
// authored on a case-sensitive filesystem produces that pair honestly. Non-ASCII
// bytes compare exactly (see this directory's CLAUDE.md on NFC/NFD).
bool sameEntryName(const std::string& a, const std::string& b);
// sameEntryName's fold made explicit: the ASCII-lower-cased bytes, so
// entryNameKey(a) == entryNameKey(b) exactly when sameEntryName(a, b). For a caller
// holding many names at once — folding them into a set is what turns an O(n^2)
// pairwise scan into a linear one.
std::string entryNameKey(const std::string& name);
// The one field in the format that CAN express a path: a nested Sample's
// relativePath, which is bank-relative by design. Rejects every absolute form
// (the shared util::isAbsolutePath test) and any ".." component — BankModel::add
// checks only the former, so traversal reaches the format without this.
bool isValidNestedSamplePath(const std::string& path);
// The fixed header, informational semver included. writerVersion is
// version::stampVersion() on the write side — it exists so a TooNew refusal can
// tell the user which build to install; it never gates. Defaults are 0/0, NOT
// the current ladder pair, so a header that never parsed reads as obviously
// unset rather than as a plausible 1/1. The fields are meaningful whenever they
// are non-zero, not only on success: a decode that got past the header and
// failed later (a corrupt manifest) reports the real pair alongside Malformed.
struct PackageHeader {
std::uint32_t formatVersion = 0;
std::uint32_t minReaderVersion = 0;
std::string writerVersion;
bool operator==(const PackageHeader& o) const {
return formatVersion == o.formatVersion &&
minReaderVersion == o.minReaderVersion &&
writerVersion == o.writerVersion;
}
};
} // namespace reasampler::package
+230
View File
@@ -0,0 +1,230 @@
#include "core/package/package_manifest.h"
#include <unordered_set>
#include <utility>
#include "core/json/json.h"
#include "core/package/package_format.h"
namespace reasampler::package {
namespace {
using json::numToStr;
using ObjWriter = json::Writer;
// Shared by serializeManifest and deserializeManifest — see this directory's
// CLAUDE.md for why duplicate names are rejected both ways. Equivalence is the
// format's, not std::string's: entryNameKey is sameEntryName's ASCII-case fold.
//
// A set, not the pairwise scan this replaced: under kMaxManifestBytes a hostile
// package can declare hundreds of thousands of minimal entries, and O(n^2) over that
// is a multi-minute hang on the decode path an import drives.
bool duplicateName(const std::vector<PackageEntry>& entries) {
std::unordered_set<std::string> seen;
seen.reserve(entries.size());
for (const auto& e : entries)
if (!seen.insert(entryNameKey(e.fileName)).second) return true;
return false;
}
// The one-sample BankModel image of `s` — bank_model's own writer, verbatim, so
// the per-sample shape has exactly one owner. nullopt when add() would reject
// the record (its guards are the format's guards too).
std::optional<std::string> nestSample(const model::Sample& s) {
model::BankModel one;
if (one.add(s) != model::AddResult::Added) return std::nullopt;
return one.serialize();
}
} // namespace
bool PackageEntry::operator==(const PackageEntry& o) const {
return fileName == o.fileName && byteLength == o.byteLength &&
byteHash == o.byteHash && sample == o.sample;
}
bool PackageManifest::operator==(const PackageManifest& o) const {
return bankDisplayName == o.bankDisplayName && exportTimestamp == o.exportTimestamp &&
entries == o.entries && slots == o.slots;
}
std::optional<std::string> serializeManifest(const PackageManifest& m) {
for (const auto& e : m.entries) {
if (!isValidEntryName(e.fileName)) return std::nullopt;
if (!isValidNestedSamplePath(e.sample.relativePath)) return std::nullopt;
// Cross-module contract with src/shell/package — see this directory's
// CLAUDE.md.
if (e.byteLength == 0) return std::nullopt;
}
if (duplicateName(m.entries)) return std::nullopt;
std::string out;
{
ObjWriter root(out);
root.keyStr("bankName", m.bankDisplayName);
root.keyRaw("exported", numToStr(m.exportTimestamp));
root.keyBegin("entries");
out += '[';
for (std::size_t i = 0; i < m.entries.size(); ++i) {
const auto& e = m.entries[i];
auto nested = nestSample(e.sample);
if (!nested) return std::nullopt;
if (i) out += ',';
ObjWriter w(out);
w.keyStr("name", e.fileName);
// byteLength rides as a signed decimal; 2^63 bytes is beyond any file.
w.keyRaw("length", numToStr(static_cast<std::int64_t>(e.byteLength)));
w.keyStr("hash", e.byteHash);
w.keyRaw("index", *nested);
}
out += ']';
root.keyBegin("slots");
out += m.slots.serialize();
} // root closes here (NRVO note in json::Writer)
return out;
}
namespace {
// Mirrors bank_book_json's private slots parser: [{id, slot}, ...] pairs handed
// to SlotMap::fromEntries, which owns the defensive repair rules. Deliberately
// does NOT reject a repeated "id"/"slot" key the way the root and entry parsers
// below reject theirs — this grammar belongs to core/model's bank_book_json, and
// diverging here would give one wire shape two behaviours in two files. The
// stakes differ too: a repeated "name" decides which file an entry lands on,
// while a repeated "id" here still feeds SlotMap::fromEntries's deterministic
// first-wins/never-double-occupy repair, so no ambiguity survives. Do not
// "finish" the repeat-key rejection here to match the parsers below.
bool parseSlots(json::Reader& r, model::SlotMap& out) {
std::vector<std::pair<std::string, int>> pairs;
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) {
out = model::SlotMap::fromEntries(pairs);
return true;
}
do {
if (!r.consume('{')) return false;
std::string id;
int slot = 0;
bool haveId = false, haveSlot = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
else { if (!r.skipValue()) return false; }
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || !haveSlot) return false;
pairs.emplace_back(std::move(id), slot);
} while (r.consume(','));
if (!r.consume(']')) return false;
out = model::SlotMap::fromEntries(pairs);
return true;
}
bool parseEntry(json::Reader& r, PackageEntry& e) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // an entry needs all four fields
bool haveName = false, haveLength = false, haveHash = false, haveSample = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
// A repeated key is rejected here exactly as at the root — same format
// question, one level down.
if (key == "name") {
if (haveName || !r.parseString(e.fileName)) return false;
haveName = true;
} else if (key == "length") {
std::int64_t v = 0;
if (haveLength || !r.parseInt64(v)) return false;
if (v < 0) return false;
e.byteLength = static_cast<std::uint64_t>(v);
haveLength = true;
} else if (key == "hash") {
if (haveHash || !r.parseString(e.byteHash)) return false;
haveHash = true;
} else if (key == "index") {
if (haveSample) return false;
std::string raw;
if (!r.captureValue(raw)) return false;
auto idx = model::BankModel::deserialize(raw);
// Exactly one sample: add()'s silent drop (rejected record) or a
// multi-sample blob both fail the entry rather than half-parse.
if (!idx || idx->size() != 1) return false;
e.sample = idx->all().front();
haveSample = true;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveName || !haveLength || !haveHash || !haveSample) return false;
return isValidEntryName(e.fileName) && isValidNestedSamplePath(e.sample.relativePath);
}
bool parseManifest(json::Reader& r, PackageManifest& m) {
if (!r.consume('{')) return false;
r.skipWs();
// "Which duplicate keys are legal" is a format contract, so it is answered
// for every root key rather than only for the one that would accumulate:
// a repeated key is rejected, never last-wins. Unknown keys may repeat —
// they are skipped, and a future format must stay free to add them.
bool haveBankName = false, haveExported = false, haveEntries = false, haveSlots = false;
const auto firstTime = [](bool& seen) { const bool ok = !seen; seen = true; return ok; };
if (!r.consume('}')) { // not the empty-object shortcut: parse the members
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "bankName") {
if (!firstTime(haveBankName)) return false;
if (!r.parseString(m.bankDisplayName)) return false;
} else if (key == "exported") {
if (!firstTime(haveExported)) return false;
if (!r.parseInt64(m.exportTimestamp)) return false;
} else if (key == "entries") {
if (!firstTime(haveEntries)) return false;
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
PackageEntry e;
if (!parseEntry(r, e)) return false;
m.entries.push_back(std::move(e));
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else if (key == "slots") {
if (!firstTime(haveSlots)) return false;
if (!parseSlots(r, m.slots)) return false;
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
}
r.skipWs();
if (!r.eof()) return false; // trailing garbage — even after an empty object
return !duplicateName(m.entries);
}
} // namespace
std::optional<PackageManifest> deserializeManifest(const std::string& json) {
PackageManifest m;
json::Reader r(json);
if (!parseManifest(r, m)) return std::nullopt;
return m;
}
} // namespace reasampler::package
+60
View File
@@ -0,0 +1,60 @@
#pragma once
// package_manifest — the RSBK manifest model and its JSON codec. Per-sample
// shape is NOT owned here: each entry nests a one-sample BankModel blob emitted
// by bank_model's own writer (the bank_book_json precedent), so a future Sample
// field reaches packages for free. Pure: no filesystem, no host types.
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/model/bank_model.h"
#include "core/model/slot_map.h"
namespace reasampler::package {
// One payload's transport record. `byteHash` is capture::hashBytes over the
// payload's raw bytes — the whole-file digest, deliberately NOT hashWavContent
// (which skips chunks and so cannot answer "did these bytes survive the trip").
// FNV-1a: a corruption detector, not a cryptographic checksum. The codec only
// carries the digest; hashing happens where the payload is streamed (shell).
struct PackageEntry {
std::string fileName; // bare name inside the package (isValidEntryName)
std::uint64_t byteLength = 0;
std::string byteHash;
model::Sample sample;
bool operator==(const PackageEntry& o) const;
};
// Everything the manifest carries besides the payloads: informational envelope
// (source bank name, export moment), the entries, and the bank's display
// positions (a bank's arrangement is part of what the user built).
struct PackageManifest {
std::string bankDisplayName;
std::int64_t exportTimestamp = 0; // unix epoch seconds
std::vector<PackageEntry> entries;
model::SlotMap slots;
bool operator==(const PackageManifest& o) const;
};
// Emits the manifest JSON. nullopt when the manifest cannot be represented: an
// invalid or duplicate entry name (duplicate by sameEntryName, not string
// equality), a nested relativePath isValidNestedSamplePath refuses, a
// zero-length entry (see this directory's CLAUDE.md — the shell's payload-append
// seam cannot round-trip one), or a sample record BankModel itself would reject
// (empty id, absolute path) — refusing on encode so an undecodable package is
// never written.
std::optional<std::string> serializeManifest(const PackageManifest& m);
// Parses manifest JSON (nullopt on malformed input, never UB). Unknown keys are
// skipped at every level, so an additive newer manifest still parses; a repeated
// KNOWN root key is rejected rather than last-wins. Rejects what encode rejects
// except the zero-length entry — names and nested paths are validated on BOTH
// directions because a package can arrive from anywhere — plus a missing
// per-entry field or a negative length.
std::optional<PackageManifest> deserializeManifest(const std::string& json);
} // namespace reasampler::package
+1
View File
@@ -38,6 +38,7 @@ OriginKind kindFromInt(int v) {
case 2: return OriginKind::Ingest;
case 3: return OriginKind::Recapture;
case 4: return OriginKind::Resample;
case 5: return OriginKind::PackageImport;
default: return OriginKind::Unknown;
}
}
+6 -5
View File
@@ -15,11 +15,12 @@ namespace reasampler::tracking {
// lifted from a legacy path-only manifest, or one whose creator did not know.
// PERSISTED AS INTEGERS: never renumber an existing value, only append.
enum class OriginKind {
Unknown = 0,
Capture = 1,
Ingest = 2,
Recapture = 3, // regenerated in place from its recorded source recipe
Resample = 4, // baked from an instrument's own processing chain
Unknown = 0,
Capture = 1,
Ingest = 2,
Recapture = 3, // regenerated in place from its recorded source recipe
Resample = 4, // baked from an instrument's own processing chain
PackageImport = 5, // package-sourced vs Ingest's user-picked; unrecoverable once merged
};
// One system-created file's birth record. `relativePath` is the key and is ALWAYS
+3 -2
View File
@@ -3,8 +3,8 @@
## Scope
Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte
loading, unit-interval clamping, the absolute-path rejection test, and the
per-segment envelope curve law.
loading, unit-interval clamping, the absolute-path rejection test, the
per-segment envelope curve law, and the ASCII-whitespace fold test.
## Modules
@@ -19,6 +19,7 @@ per-segment envelope curve law.
before curves existed play unchanged, and what the knob law's centre detent exists to keep
reachable from the dial.
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
- `ascii_ws` (`core/util`, header-only) — the ONE ASCII-whitespace test (space/tab/CR/LF) behind `BankBook::nameKey`'s trim, shared by `core/package/import_plan`'s blank-bank-name fallback. Same rationale as `relative_path`: two independently-maintained copies could drift on what counts as blank.
## Gotchas
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// ascii_ws — the ONE ASCII-whitespace test shared by every fold that must agree with
// BankBook::nameKey's trim (space/tab/CR/LF): a drifted second copy could accept a
// package bank name nameKey would treat as blank, or vice versa.
namespace reasampler::util {
inline bool isAsciiWs(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
} // namespace reasampler::util
+14 -3
View File
@@ -42,7 +42,9 @@ settled 2026-07-23):
reapply touches solo not at all.
- **GUID-keyed, reorder-safe.** Membership keys on track GUID (`GetTrackGUID`),
never track index; tolerates unknown/stale GUIDs (pruned on reconcile via
`ViewModeModel::reconcile(liveGuids)`).
`ViewModeModel::reconcile(liveGuids)`). The same rule binds one level down: a
parked track's per-FX offline state keys on the FX's own identity
(`TrackFX_GetFXGUID`), never its slot — see `fx_offline`.
- **Relative/portable state only** in the persisted view section (GUID strings,
mode ids — no absolute paths, no index positions).
- **Show-both semantics.** A per-track "pin visible across modes" flag that
@@ -64,8 +66,11 @@ settled 2026-07-23):
- **Mechanism: fixed item lanes.** Map mode → lane; toggle drives per-lane
play/show so only the active mode's lane is present. Items keep their real
position and real track — nothing is moved in time or deleted.
- **Membership: adoption rule for new items; active mode for new tracks.** New
tracks are tagged to the active mode at creation. New items follow an
- **Membership: adoption rule for new items; active mode for new tracks absent an
explicit tag.** New tracks are tagged to the active mode at creation **only when the GUID carries no
membership record** — an explicit tag wins over the detector, because the detector
classifies content the *user* made, not content the tool made and already
classified. New items follow an
adoption rule: if the item's track has pre-existing managed-eligible content
spanning exactly one mode, the item adopts that mode; the active-mode
fallback applies only when the track is empty or already spans multiple
@@ -92,6 +97,7 @@ settled 2026-07-23):
## Modules
- `view_mode_model` — Design View mode system: mode registry, GUID-keyed membership, folder-tree-aware visibility derivation, snapshot-based park/restore planner, the per-mode `SoloCache` it owns, JSON round-trip.
- `fx_offline` — the per-FX offline snapshot's KEY and its restore resolution: `FxKeying` (Identity / Slot), `FxOfflineState`, the planned `FxOfflineOp`, and `resolveFxRestore`, which matches each captured state to the FX it came from against the chain as it stands at restore time. An identity that is no longer live is DROPPED and counted (`FxRestoreDrops`, reported through `describeFxRestoreDrops`), never re-pointed at a slot — see the `FxKeying` and `resolveFxRestore` comments in `fx_offline.h` for why. Slot keying survives only for snapshots lifted from a pre-identity `view_state` and for park plans, where every live slot is the target by construction.
- `solo_cache` — the per-mode solo surface: `SoloCache` (mode id → GUID → raw `I_SOLO`), the soloed-subset filter, and `planSoloRestore`, whose two drop rules (dead GUID, not visible in the incoming mode) and their reasoning live in its header.
- `view_tree` — pure `I_FOLDERDEPTH`→FolderTree helper for the Design View shell.
- `mode_switch` — REAPER-free segment layout + hit-test for the bank_panel's Design View mode switch.
@@ -107,6 +113,11 @@ settled 2026-07-23):
- `kManagedLanePrefix` ("reasampler:") is stable-forever like an action-id
string — changing it strands the ownership of every already-minted lane in
every already-saved project.
- The `view_state` blob's version ladder lives beside `kViewStateVersion` in
`view_mode_model.cpp`. v2 writes the v1 slot array BESIDE the identity array so
a downgrade keeps the behavior it had; the version field is written but
deliberately not validated on read, because an unreadable `view_state` falls
back to a default model and loses every membership tag.
- `guid_diff::GuidBaseline` must have `reset()` called on every detected
project switch, or the next `observe()` will diff across two unrelated
projects and mass-tag (or miss) content.
+8 -4
View File
@@ -4,12 +4,16 @@ reasampler_test(lane_keys LINK lane_keys)
reasampler_pure_library(solo_cache SOURCES solo_cache.cpp)
reasampler_test(solo_cache LINK solo_cache)
# lane_keys and solo_cache are PUBLIC: the lane-minting plan names managed lanes through the
# one durable-key convention, and ViewModeModel exposes the SoloCache by reference, so every
# consumer has to resolve those symbols too.
reasampler_pure_library(fx_offline SOURCES fx_offline.cpp)
reasampler_test(fx_offline LINK fx_offline)
# lane_keys, solo_cache and fx_offline are PUBLIC: the lane-minting plan names managed lanes
# through the one durable-key convention, ViewModeModel exposes the SoloCache by reference, and
# TrackSnapshot/TrackPlan carry the FX-offline types by value — so every consumer has to
# resolve those symbols too.
reasampler_pure_library(view_mode_model
SOURCES view_mode_model.cpp
LINK PRIVATE json PUBLIC lane_keys solo_cache)
LINK PRIVATE json PUBLIC lane_keys solo_cache fx_offline)
reasampler_test(view_mode_model LINK view_mode_model)
reasampler_pure_library(view_tree SOURCES view_tree.cpp LINK PUBLIC view_mode_model)
+92
View File
@@ -0,0 +1,92 @@
#include "core/view/fx_offline.h"
#include <cstddef>
#include <map>
namespace reasampler {
namespace {
// Identity -> current slot. First occurrence wins: REAPER mints one GUID per FX
// instance, so a repeat can only come from a corrupt/hand-edited chain, and
// picking one deterministically beats writing twice.
std::map<std::string, int> slotByIdentity(const std::vector<std::string>& liveFxGuids) {
std::map<std::string, int> byGuid;
for (std::size_t i = 0; i < liveFxGuids.size(); ++i) {
if (liveFxGuids[i].empty()) continue; // unidentifiable FX is not a restore target
byGuid.emplace(liveFxGuids[i], static_cast<int>(i));
}
return byGuid;
}
} // namespace
FxRestoreResolution resolveFxRestore(const std::vector<FxOfflineOp>& planned,
const std::vector<std::string>& liveFxGuids) {
FxRestoreResolution res;
const std::map<std::string, int> byGuid = slotByIdentity(liveFxGuids);
const int liveCount = static_cast<int>(liveFxGuids.size());
for (const FxOfflineOp& op : planned) {
if (op.keying == FxKeying::Identity) {
if (op.fxGuid.empty()) {
// REAPER reported no GUID at capture time — distinct from a real
// captured identity going missing: the FX may still be live, we
// just never had a name for it. Counted separately so the report
// never claims it was deleted (see describeFxRestoreDrops).
++res.drops.unidentified;
continue;
}
auto it = byGuid.find(op.fxGuid);
if (it == byGuid.end()) {
++res.drops.missingIdentity;
continue;
}
res.writes.push_back(FxOfflineWrite{it->second, op.offline});
} else {
if (op.slot < 0 || op.slot >= liveCount) {
++res.drops.slotOutOfRange;
continue;
}
res.writes.push_back(FxOfflineWrite{op.slot, op.offline});
}
}
return res;
}
std::string describeFxRestoreDrops(const FxRestoreDrops& drops, int trackCount) {
if (drops.total() <= 0) return {};
std::string msg = "ReaSampler: Design View restore dropped " +
std::to_string(drops.total()) + " captured FX offline state(s) on " +
std::to_string(trackCount) + " track(s) -- ";
std::vector<std::string> clauses;
if (drops.missingIdentity > 0) {
clauses.push_back(std::to_string(drops.missingIdentity) +
" FX no longer in the chain (deleted or replaced while parked)");
}
if (drops.unidentified > 0) {
clauses.push_back(std::to_string(drops.unidentified) +
" FX REAPER could not identify at capture time (no GUID reported), "
"so it could not be matched now");
}
if (drops.slotOutOfRange > 0) {
clauses.push_back(std::to_string(drops.slotOutOfRange) +
" from a project saved before FX identity was recorded, whose slot no longer exists");
}
for (std::size_t i = 0; i < clauses.size(); ++i) {
if (i) msg += ", ";
msg += clauses[i];
}
// Every dropped entry is still sitting exactly where park left it — offline
// — because the restore that would have flipped it back never ran. Say
// that, not the reassuring-but-wrong "left as it was" (park itself was the
// change; restore is what didn't happen for these).
msg += ". Each was left offline, as park left it, with no snapshot left to "
"restore it -- switch it back on by hand.\n";
return msg;
}
} // namespace reasampler
+93
View File
@@ -0,0 +1,93 @@
#pragma once
// The per-FX offline snapshot's key and its restore resolution: what a captured
// FX state is keyed BY, and how that key resolves against the chain as it stands
// at restore time. Pure — the FX identity is an opaque string the shell reads
// from REAPER (TrackFX_GetFXGUID) and hands in.
#include <string>
#include <vector>
namespace reasampler {
// How a set of per-FX entries is keyed. `Slot` is the position-addressed shape:
// a park plan (every live slot, by construction) or a snapshot lifted from a
// project saved before identity was recorded. A live snapshot is always
// `Identity` — a chain reordered while the track is parked makes a slot a lie.
enum class FxKeying { Identity, Slot };
// One FX's captured offline state. Under Identity keying `fxGuid` is that FX's
// own durable identity; under Slot keying it is empty and the entry's POSITION
// in the snapshot is the slot it was captured from.
struct FxOfflineState {
std::string fxGuid;
int offline = 0; // int, not bool — mirrors TrackSnapshot's defensive contract
bool operator==(const FxOfflineState& o) const {
return fxGuid == o.fxGuid && offline == o.offline;
}
};
// One per-FX offline write as PLANNED. The keying travels with the op; see
// FxKeying above and resolveFxRestore below for why a missing identity is
// never re-pointed at a slot.
struct FxOfflineOp {
std::string guid; // track GUID
FxKeying keying = FxKeying::Identity;
std::string fxGuid; // FX identity, under Identity keying
int slot = 0; // write target under Slot keying only
bool offline = false;
bool operator==(const FxOfflineOp& o) const {
return guid == o.guid && keying == o.keying && fxGuid == o.fxGuid &&
slot == o.slot && offline == o.offline;
}
};
// One resolved write: TrackFX_SetOffline(track, fxIndex, offline).
struct FxOfflineWrite {
int fxIndex = 0;
bool offline = false;
bool operator==(const FxOfflineWrite& o) const {
return fxIndex == o.fxIndex && offline == o.offline;
}
};
// Captured state a restore could not apply. All three counts mean the same
// act: the entry was dropped and no FX was written in its place.
struct FxRestoreDrops {
int missingIdentity = 0; // identity-keyed entry with no live FX carrying that GUID
int unidentified = 0; // identity-keyed entry whose captured fxGuid was itself empty
int slotOutOfRange = 0; // slot-keyed entry whose capture-time slot no longer exists
int total() const { return missingIdentity + unidentified + slotOutOfRange; }
void add(const FxRestoreDrops& o) {
missingIdentity += o.missingIdentity;
unidentified += o.unidentified;
slotOutOfRange += o.slotOutOfRange;
}
bool operator==(const FxRestoreDrops& o) const {
return missingIdentity == o.missingIdentity && unidentified == o.unidentified &&
slotOutOfRange == o.slotOutOfRange;
}
};
struct FxRestoreResolution {
std::vector<FxOfflineWrite> writes; // in planned order
FxRestoreDrops drops;
};
// Resolves each planned op against the live chain, where `liveFxGuids[i]` is the
// identity of the FX at slot `i` right now (empty when REAPER reported none).
// An identity that is not live is DROPPED, never re-pointed at a slot; a live FX
// no op names is left entirely alone.
FxRestoreResolution resolveFxRestore(const std::vector<FxOfflineOp>& planned,
const std::vector<std::string>& liveFxGuids);
// One console line describing a whole apply's drops, or "" when nothing was
// dropped — the degrade is reported rather than swallowed.
std::string describeFxRestoreDrops(const FxRestoreDrops& drops, int trackCount);
} // namespace reasampler
+108 -8
View File
@@ -196,8 +196,13 @@ TrackPlan makeParkPlan(const std::string& guid, int fxCount) {
{guid, Flag::MainSend, 0},
{guid, Flag::FxEnable, 0},
};
// Park has no identity question to answer: it offlines every slot that is
// live right now, so slot keying IS the addressing. In production fxCount
// is always 0 here — planToggle calls this with fxCount=0 and the D2 shell
// expands the real writes itself via TrackFX_GetCount (parkFxOffline in
// shell/view/view.cpp); a nonzero fxCount only exercises this loop in tests.
for (int i = 0; i < fxCount; ++i)
p.fxOffline.push_back({guid, i, true});
p.fxOffline.push_back(FxOfflineOp{guid, FxKeying::Slot, {}, i, true});
return p;
}
@@ -209,8 +214,11 @@ TrackPlan makeRestorePlan(const std::string& guid, const TrackSnapshot& snap) {
{guid, Flag::MainSend, snap.mainSend},
{guid, Flag::FxEnable, snap.fxEnable},
};
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i)
p.fxOffline.push_back({guid, static_cast<int>(i), snap.fxOffline[i] != 0});
for (std::size_t i = 0; i < snap.fxOffline.size(); ++i) {
const FxOfflineState& fx = snap.fxOffline[i];
p.fxOffline.push_back(FxOfflineOp{guid, snap.fxKeying, fx.fxGuid,
static_cast<int>(i), fx.offline != 0});
}
return p;
}
@@ -361,13 +369,33 @@ using json::writeIntArray;
std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;
// Version ladder for the stored blob, under the FOREVER-STABLE "view_state" key.
// Only the per-FX snapshot shape has ever moved:
//
// v1 "snapshots":[{...,"fxOffline":[0,1,0]}] — offline state by SLOT
// v2 "snapshots":[{...,"fxOffline":[0,1,0], — the v1 array, still written
// "fx":[{"guid":"{..}","offline":0},...]}] — by FX IDENTITY
//
// v2 writes BOTH: "fx" is what this build reads, and the v1 array is what a build
// that predates identity keying reads — a downgrade keeps exactly the behavior it
// had rather than losing every captured FX state to an unknown key. Reading, "fx"
// wins outright; a blob carrying only "fxOffline" lifts to a Slot-keyed snapshot
// and restores by slot ONCE, which is the only thing its bytes can support (the
// restore then clears it, so the next park captures identities).
//
// "version" is WRITTEN but deliberately not validated on read: an unreadable
// view_state falls back to a default model, which loses every membership tag, so
// leniency is the safe direction here — the opposite call from origin_ledger,
// where a misread blob would put prune's deletion authority on bad data.
constexpr int kViewStateVersion = 2;
} // namespace
std::string ViewModeModel::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", intToStr(1));
root.keyRaw("version", intToStr(kViewStateVersion));
root.keyStr("activeMode", activeModeId_);
root.keyBegin("modes");
@@ -410,7 +438,8 @@ std::string ViewModeModel::serialize() const {
}
out += ']';
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable, fxOffline[] }
// snapshots: array of { guid, showInTcp, showInMixer, mainSend, fxEnable,
// fxOffline[], fx[] } — see the version ladder above.
root.keyBegin("snapshots");
out += '[';
{
@@ -424,8 +453,28 @@ std::string ViewModeModel::serialize() const {
e.keyRaw("showInMixer", intToStr(snap.showInMixer));
e.keyRaw("mainSend", intToStr(snap.mainSend));
e.keyRaw("fxEnable", intToStr(snap.fxEnable));
std::vector<int> bySlot;
bySlot.reserve(snap.fxOffline.size());
for (const FxOfflineState& fx : snap.fxOffline) bySlot.push_back(fx.offline);
e.keyBegin("fxOffline");
writeIntArray(out, snap.fxOffline);
writeIntArray(out, bySlot);
// A Slot-keyed snapshot has no identities to write — emitting an
// "fx" array for it would invent the very keys it lacks.
if (snap.fxKeying == FxKeying::Identity) {
e.keyBegin("fx");
out += '[';
bool firstFx = true;
for (const FxOfflineState& fx : snap.fxOffline) {
if (!firstFx) out += ',';
firstFx = false;
ObjWriter f(out);
f.keyStr("guid", fx.fxGuid);
f.keyRaw("offline", intToStr(fx.offline));
}
out += ']';
}
}
}
out += ']';
@@ -537,6 +586,32 @@ bool parseMembership(json::Reader& r, MembershipIndex& idx) {
return r.consume(']');
}
// The identity-keyed "fx" array. Both keys are mandatory (strict like parseLanes);
// an EMPTY guid is accepted, because a live capture records one when REAPER
// reported no identity for that FX — the entry is honest about being unresolvable
// rather than being silently dropped at write time.
bool parseFxStates(json::Reader& r, std::vector<FxOfflineState>& out) {
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true;
do {
if (!r.consume('{')) return false;
FxOfflineState fx;
bool haveGuid = false, haveOffline = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "guid") { if (!r.parseString(fx.fxGuid)) return false; haveGuid = true; }
else if (k == "offline") { if (!r.parseInt(fx.offline)) return false; haveOffline = true; }
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveGuid || !haveOffline) return false;
out.push_back(fx);
} while (r.consume(','));
return r.consume(']');
}
bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps) {
if (!r.consume('[')) return false;
r.skipWs();
@@ -546,6 +621,10 @@ bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps
std::string guid;
TrackSnapshot snap;
bool haveGuid = false;
std::vector<int> bySlot;
bool haveSlot = false;
std::vector<FxOfflineState> byIdentity;
bool haveIdentity = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
@@ -554,12 +633,33 @@ bool parseSnapshots(json::Reader& r, std::map<std::string, TrackSnapshot>& snaps
else if (k == "showInMixer") { if (!r.parseInt(snap.showInMixer)) return false; }
else if (k == "mainSend") { if (!r.parseInt(snap.mainSend)) return false; }
else if (k == "fxEnable") { if (!r.parseInt(snap.fxEnable)) return false; }
else if (k == "fxOffline") { if (!r.parseIntArray(snap.fxOffline)) return false; }
else if (k == "fxOffline") { if (!r.parseIntArray(bySlot)) return false; haveSlot = true; }
else if (k == "fx") {
if (!parseFxStates(r, byIdentity)) return false;
haveIdentity = true;
}
else if (!r.skipValue()) return false;
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveGuid || guid.empty()) return false;
snaps[guid] = snap;
// A blob carrying both arrays at different lengths is not something this
// writer (or any prior version) produces — reject rather than silently
// trusting "fx" over a slot array that disagrees with it; an unreadable
// view_state falls back to a default model per the version-ladder note
// above, which is the same leniency-direction call already made there.
if (haveIdentity && haveSlot && byIdentity.size() != bySlot.size()) return false;
// "fx" wins outright — v2 writes the slot array beside it purely so an
// older build can still read something (see the version ladder above).
if (haveIdentity) {
snap.fxOffline = std::move(byIdentity);
snap.fxKeying = FxKeying::Identity;
} else {
for (int offline : bySlot) snap.fxOffline.push_back(FxOfflineState{{}, offline});
snap.fxKeying = FxKeying::Slot;
}
snaps[guid] = std::move(snap);
} while (r.consume(','));
return r.consume(']');
}
+11 -17
View File
@@ -12,6 +12,7 @@
#include <string>
#include <vector>
#include "core/view/fx_offline.h"
#include "core/view/solo_cache.h"
namespace reasampler {
@@ -201,13 +202,16 @@ struct TrackSnapshot {
int mainSend = 0; // B_MAINSEND prior value
int fxEnable = 0; // I_FXEN prior value
// Prior per-FX offline state, index = fx slot.
std::vector<int> fxOffline;
// Prior per-FX offline state in capture-time slot order, keyed per fxKeying:
// by the FX's own identity (live capture), or by position (a snapshot lifted
// from a project saved before identity was recorded).
std::vector<FxOfflineState> fxOffline;
FxKeying fxKeying = FxKeying::Identity;
bool operator==(const TrackSnapshot& o) const {
return showInTcp == o.showInTcp && showInMixer == o.showInMixer &&
mainSend == o.mainSend && fxEnable == o.fxEnable &&
fxOffline == o.fxOffline;
fxOffline == o.fxOffline && fxKeying == o.fxKeying;
}
};
@@ -231,17 +235,6 @@ struct TrackFlagOp {
}
};
// One per-FX offline write: TrackFX_SetOffline(guid, fxIndex, offline).
struct FxOfflineOp {
std::string guid;
int fxIndex = 0;
bool offline = false;
bool operator==(const FxOfflineOp& o) const {
return guid == o.guid && fxIndex == o.fxIndex && offline == o.offline;
}
};
// One managed-lane play/show write the shell must apply (translated into
// C_LANEPLAYS / I_FIXEDLANE / B_FIXEDLANE_HIDDEN). Emitted for MANAGED lanes
// only — never a manual lane; enforced in planToggle and mirrored by
@@ -257,9 +250,10 @@ struct LanePlayOp {
};
// The complete set of operations to park one inactive leaf, or restore one
// leaf. Park uses fixed zeros; restore uses a snapshot's values. fxOffline is
// per known FX slot: on park all slots go offline (from the snapshot's slot
// count); on restore each slot returns to its captured value.
// leaf. Park uses fixed zeros; restore uses a snapshot's values. Park's
// fxOffline ops are slot-keyed (every live slot goes offline); restore's carry
// the snapshot's keying and are resolved against the live chain by
// resolveFxRestore before any write.
struct TrackPlan {
std::vector<TrackFlagOp> flags;
std::vector<FxOfflineOp> fxOffline;