Ψ-W3 remediation: fix the verify doc's wrong render source, add missing content checks, soften unverified claims

Corrected the Render-dialog source name the refusal's evidence depends on, added a by-ear content check and a file-size channel proxy, and stopped two comments from overclaiming.
This commit is contained in:
2026-08-01 23:01:50 -04:00
parent f69c4bf6bf
commit 2a9ab65944
11 changed files with 125 additions and 46 deletions
+27 -5
View File
@@ -527,11 +527,16 @@ message where a correct summed file used to land.
**Intended fix.** Run the observation in `docs/verify-track-scope-multitrack.md` §3 (a **Intended fix.** Run the observation in `docs/verify-track-scope-multitrack.md` §3 (a
hand-driven Render dialog, source "selected tracks via master", one literal filename, two hand-driven Render dialog, source "selected tracks via master", one literal filename, two
tracks selected — then count the files REAPER writes). If it comes back "one summed tracks selected — then count the files REAPER writes). If it comes back "one file per
file", the refusal is over-strict for the TRACK scope and should be narrowed back; the track", nothing to do and the inference is retired into fact. If it comes back "one
ITEM-scope half stays regardless, since a per-item-track render is not the sum the user summed file", the refusal is over-strict for the TRACK scope and should be narrowed back
asked for either way. If it comes back "one file per track", nothing to do and the — and the ITEM-scope half is then an OPEN question, not settled: a full-extent item
inference is retired into fact. capture already sums a multi-track item selection via `&32|single-file`
(`tests/test_render_settings.cpp:262`), so if `&128` also sums, a ranged item capture
routed through it sums too, and keeping the item refusal in that branch would make item
scope inconsistent with itself across the range boundary (full-extent sums, ranged
refuses, same scope). Whether that inconsistency is acceptable or the item refusal should
narrow too needs its own look at that point — not decided here.
**The constraint the fix MUST handle.** Narrowing the refusal must keep the ITEM scope **The constraint the fix MUST handle.** Narrowing the refusal must keep the ITEM scope
refusing, must keep `renderOffline` the single seam (so a recipe replay cannot diverge refusing, must keep `renderOffline` the single seam (so a recipe replay cannot diverge
@@ -645,3 +650,20 @@ stereo file today.
**Done looks like.** A dead-center instrument bake lands as a 1-channel file with **Done looks like.** A dead-center instrument bake lands as a 1-channel file with
`Sample::channelCount` matching, the same way an offline dead-center capture does; a `Sample::channelCount` matching, the same way an offline dead-center capture does; a
true-stereo bake is byte-identical to today's output. true-stereo bake is byte-identical to today's output.
## A 0-byte render can pass every gate and land as `Ok` (pre-existing, not a Ψ-W3 regression)
**Context (surfaced by Ψ-W3 review).** `OfflineRenderBackend::capture`'s exists-check
(`capture.cpp:489`) passes for a 0-byte file, and the bounds gate (`:507-546`) only fires
when `expectedFrames > 0` — an invalid/empty layout reads `expectedFrames == 0` and skips
the gate rather than refusing. A 0-byte render can therefore reach `stampCaptureSample`
and land as `CaptureStatus::Ok` with an empty `contentHash` and `channelCount == 0`.
**Not introduced by Ψ-W3.** The exists-check and the `expectedFrames > 0` guard both
predate this track; Ψ-W3 only added the mono-collapse failure report that sits downstream
of this hole and was careful not to assert bytes it never verified (see
`reportCollapseFailure` in `capture.cpp`).
**Intended fix.** After the exists-check, also reject a 0-byte file explicitly (its own
status, not folded into `BoundsMismatch`, since a 0-byte file was never bounds-checked at
all) before anything downstream reads it.
+28 -11
View File
@@ -30,9 +30,15 @@ Read off:
to ±1 — what you are confirming here is that it landed at all. to ±1 — what you are confirming here is that it landed at all.
- The REAPER console shows **no** `ReaSampler capture failed:` line. - The REAPER console shows **no** `ReaSampler capture failed:` line.
- Track `A` is still the only selected track afterwards. - Track `A` is still the only selected track afterwards.
- **Content, not just length.** Listen to the landed file. `A` and `B` carry *audibly
Repeat with a **razor area** on `A` over the same 1012 s span and no time selection: same different* content by the project setup above (tone vs. drum loop), so this is a by-ear
three readings. check, not a null test: the capture must be the tone alone, with **no** drum-loop bleed.
Expected: pure tone, matching `A` soloed. Failing: any trace of `B`'s drum loop audible
in the file. This is not a tautology check — the SDK header's own `RENDER_SETTINGS` line
admits a second reading, `(&(1|2)==0)=master mix`, under which a single-track track
capture could render the **whole master mix** (both `A` and `B`) rather than `A` alone;
drum-loop bleed here is exactly what that misreading would produce, and this is the
cheapest place in the whole doc to catch it.
**This is the byte-identical floor.** If either cell now refuses, the change is wrong — **This is the byte-identical floor.** If either cell now refuses, the change is wrong —
the refusal must fire only above one track. the refusal must fire only above one track.
@@ -69,7 +75,10 @@ are believed to produce N files. That has never been observed.
Drive REAPER's own Render dialog by hand, with the extension out of the loop: Drive REAPER's own Render dialog by hand, with the extension out of the loop:
1. Select `A` and `B`. 1. Select `A` and `B`.
2. File → Render. **Source:** *Selected tracks (stems)* — the dialog wording for `&128`. 2. File → Render. **Source:** *Selected tracks via master* — the dialog wording for `&128`
(SDK header ~3041). Do **not** pick *Stems (selected tracks)* — that is `&2`, a
different source bit that unambiguously writes one file per track and would confirm
nothing about `&128`.
**Bounds:** *Custom time range*, 10.000 to 12.000 s. **Bounds:** *Custom time range*, 10.000 to 12.000 s.
3. **File name:** a literal stem with **no wildcards at all** — e.g. `stemprobe`. Clear 3. **File name:** a literal stem with **no wildcards at all** — e.g. `stemprobe`. Clear
`$track` / `$item` / anything else from the pattern; the extension writes exactly one `$track` / `$item` / anything else from the pattern; the extension writes exactly one
@@ -122,16 +131,24 @@ the divergence from §2 is deliberate and this cell is what confirms it is real.
## 7. Mono collapse — what is and is not reachable ## 7. Mono collapse — what is and is not reachable
Capture a range on a track whose content is dead-center (a mono source panned center, or Capture a range on a track whose content is dead-center (a mono source panned center, or
a duplicated-channel file). Read off: a duplicated-channel file), using time selection **10.000 s to 12.000 s** (2.000 s, 96000
frames at 48000 Hz — the §1 convention, so the resulting file size is exact). The panel has
no channel-count readout anywhere (`Sample::channelCount` is not drawn by
`src/shell/panel/panel_render.cpp`), so read the proxy instead:
- The panel card reports **1 channel**. - Check the landed `.wav`'s size on disk (Explorer → Properties, or a directory listing). A
- The console shows **no** `the lossless mono collapse ... landed intact, as captured.` successful collapse is the extension's own rebuild — canonical 44-byte header + 96000 ×
line. 4 bytes = **384,044 bytes**. A file near double that (~768,044 bytes, plus whatever
REAPER's own render adds for `bext`/metadata chunks) means the collapse did not fire —
recheck the source is genuinely dead-center before treating this as a defect.
- The console shows **no** `the lossless mono collapse ... already reached the bank; only
the size win from the collapse was lost.` line.
**Not DAW-reachable:** the collapse's *failure* branch. It fires only if the captured file **Not DAW-reachable:** the collapse's *failure* branch. It fires only if the captured file
cannot be read, or its temporary rewrite cannot be written or renamed, inside the same cannot be read, or its temporary rewrite cannot be written or renamed, inside the same
call that just rendered the file — there is no manual way to inject that fault between the call that just rendered the file — there is no manual way to inject that fault between the
render and the rename. The branch is covered only at its reporting seam render and the rename. The branch is covered only at its reporting seam
(`tests/test_wav_codec.cpp`, `testCollapseOutcomesReportDistinctly`), and its console line (`tests/test_wav_codec.cpp`, `testCollapseOutcomeSuffixesAreDistinctStrings`), and its console line
has never been seen in a running REAPER. If you ever do see it, the capture in question is has never been seen in a running REAPER. If you ever do see it, the render already reached
intact and correctly measured — only the size win was lost. the bank — the report only tells you the collapse's size win was lost, not that the bytes
were verified (see `docs/TODO.md`'s 0-byte-render entry).
+5
View File
@@ -117,6 +117,11 @@ bool isMultiTrackStemRender(SourceMode mode, int sourceTrackCount) {
} }
std::string multiTrackRefusalMessage(CaptureScope scope) { std::string multiTrackRefusalMessage(CaptureScope scope) {
// Deliberately does not name realtime capture as a way out, though it is the one
// action that sums correctly here: realtime is non-deterministic (hardware/performed
// FX, no bit-identical-repeats guarantee), so pointing an offline refusal at it would
// trade one invariant for another rather than just naming a substitute. A stated
// choice, not an oversight.
switch (scope) { switch (scope) {
case CaptureScope::Item: case CaptureScope::Item:
return "This range is narrower than the selected items, so it renders " return "This range is narrower than the selected items, so it renders "
+3 -2
View File
@@ -1,8 +1,9 @@
#pragma once #pragma once
// render_settings — the REAPER-free logic behind the capture action family: // render_settings — the REAPER-free logic behind the capture action family:
// sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union, // sourceMode -> RENDER_SETTINGS bits, P_RAZOREDITS parsing + range union,
// razor-else-time inference, the FX-scope bypass plan, and the capture-action // razor-else-time inference, the FX-scope bypass plan, the capture-action
// table main.cpp iterates. Bit MEANINGS below are transcribed verbatim from // table main.cpp iterates, and the multi-track-stem refusal + its user-facing
// message text. Bit MEANINGS below are transcribed verbatim from
// reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested. // reaper_plugin_functions.h; the CHOICE of which bits each mode sets is tested.
#include <string> #include <string>
+5 -1
View File
@@ -129,7 +129,11 @@ enum class MonoCollapseOutcome {
// The capture message's collapse suffix — empty for Declined, so a capture that had // The capture message's collapse suffix — empty for Declined, so a capture that had
// nothing to collapse reads exactly as it did before the collapse existed. Shared by // nothing to collapse reads exactly as it did before the collapse existed. Shared by
// both backends so one outcome cannot be reported two ways. // both backends so one outcome cannot be reported two ways. NOT user-observable on its
// own: CaptureResult::message on a successful capture is never printed by any caller, so
// the Collapsed/Failed text this returns reaches no one today — the one observable
// channel for a genuine Failed outcome is the backends' own reportCollapseFailure
// console line.
std::string monoCollapseSuffix(MonoCollapseOutcome outcome); std::string monoCollapseSuffix(MonoCollapseOutcome outcome);
// --- Content identity (dedup hashes) ----------------------------------------- // --- Content identity (dedup hashes) -----------------------------------------
+20 -14
View File
@@ -231,23 +231,28 @@ CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
namespace { namespace {
// One console line per genuine collapse failure. The capture itself is intact and was // One console line per genuine collapse failure — silence here is what made a failed
// measured after this step, so the failure costs only the size win — but silence here is // rewrite read exactly like a legitimately stereo capture. Deliberately does NOT claim
// what made a failed rewrite read exactly like a legitimately stereo capture. // the captured bytes are intact: a 0-byte render can reach this branch too (it passes the
void reportCollapseFailure(const std::string& absolutePath, const char* what) { // exists/bounds gates upstream; see docs/TODO.md), and this path never verified the bytes
ShowConsoleMsg(("ReaSampler capture: the lossless mono collapse " + std::string(what) + // it's reporting on.
" -- " + absolutePath + void reportCollapseFailure(const std::string& absolutePath, const char* what,
" landed intact, as captured.\n").c_str()); const char* consoleLabel) {
ShowConsoleMsg((std::string(consoleLabel) + ": the lossless mono collapse " +
std::string(what) + " -- " + absolutePath +
" already reached the bank; only the size win from the collapse "
"was lost.\n").c_str());
} }
} // namespace } // namespace
MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath) { MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath,
const char* consoleLabel) {
const std::vector<std::uint8_t> bytes = util::readFileBytes(absolutePath); const std::vector<std::uint8_t> bytes = util::readFileBytes(absolutePath);
if (bytes.empty()) { if (bytes.empty()) {
// Failed, not Declined: the read that would have decided never happened, so // Failed, not Declined: the read that would have decided never happened, so
// "the channels differ" is a claim this path cannot make. // "the channels differ" is a claim this path cannot make.
reportCollapseFailure(absolutePath, "could not read the captured file"); reportCollapseFailure(absolutePath, "could not read the captured file", consoleLabel);
return MonoCollapseOutcome::Failed; return MonoCollapseOutcome::Failed;
} }
@@ -265,7 +270,7 @@ MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath)
{ {
std::ofstream out(tempPath, std::ios::binary | std::ios::trunc); std::ofstream out(tempPath, std::ios::binary | std::ios::trunc);
if (!out) { if (!out) {
reportCollapseFailure(absolutePath, "could not open its temporary file"); reportCollapseFailure(absolutePath, "could not open its temporary file", consoleLabel);
return MonoCollapseOutcome::Failed; return MonoCollapseOutcome::Failed;
} }
out.write(reinterpret_cast<const char*>(collapse.bytes.data()), out.write(reinterpret_cast<const char*>(collapse.bytes.data()),
@@ -275,7 +280,7 @@ MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath)
if (!wroteOk) { if (!wroteOk) {
std::error_code ec; std::error_code ec;
std::filesystem::remove(tempPath, ec); std::filesystem::remove(tempPath, ec);
reportCollapseFailure(absolutePath, "could not write the rebuilt file"); reportCollapseFailure(absolutePath, "could not write the rebuilt file", consoleLabel);
return MonoCollapseOutcome::Failed; return MonoCollapseOutcome::Failed;
} }
} }
@@ -283,7 +288,7 @@ MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath)
std::filesystem::rename(tempPath, absolutePath, ec); std::filesystem::rename(tempPath, absolutePath, ec);
if (ec) { if (ec) {
std::filesystem::remove(tempPath, ec); // don't leave litter on a failed rename std::filesystem::remove(tempPath, ec); // don't leave litter on a failed rename
reportCollapseFailure(absolutePath, "could not replace the captured file"); reportCollapseFailure(absolutePath, "could not replace the captured file", consoleLabel);
return MonoCollapseOutcome::Failed; return MonoCollapseOutcome::Failed;
} }
return MonoCollapseOutcome::Collapsed; return MonoCollapseOutcome::Collapsed;
@@ -497,8 +502,9 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
// (within a tolerance, see below) the requested window's frames, so a source // (within a tolerance, see below) the requested window's frames, so a source
// mode that silently widened the render fails loudly here instead of landing as // mode that silently widened the render fails loudly here instead of landing as
// a successful capture. Auto and Manual add frames by design and are skipped. // a successful capture. Auto and Manual add frames by design and are skipped.
// (The landed file is read three times on this path — this gate, the mono collapse, // (On TailMode::None the landed file is read three times on this path — this gate,
// and stampCaptureSample — plus one rewrite when the collapse fires; a // the mono collapse, and stampCaptureSample — plus one rewrite when the collapse
// fires; Auto/Manual skip this gate entirely, so they read it twice. A
// once-per-capture cost on an already-warm file, judged acceptable.) A // once-per-capture cost on an already-warm file, judged acceptable.) A
// bounded/header-only read is not a clean substitute: parseWavLayout only marks the // bounded/header-only read is not a clean substitute: parseWavLayout only marks the
// data chunk valid when the buffer holds the chunk's FULL declared body // data chunk valid when the buffer holds the chunk's FULL declared body
+12 -9
View File
@@ -80,14 +80,14 @@ struct CaptureRequest {
// Every failure is an explicit code, never a thrown exception across the REAPER boundary. // Every failure is an explicit code, never a thrown exception across the REAPER boundary.
enum class CaptureStatus { enum class CaptureStatus {
Ok, Ok,
NoProject, // no active project to render / resolve a bank folder NoProject, // no active project to render / resolve a bank folder
EmptyRange, // start >= end: nothing to render EmptyRange, // start >= end: nothing to render
UnsupportedMode, // backend does not implement this source mode UnsupportedMode, // backend does not implement this source mode
UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only) UnsupportedFormat, // requested bit depth has no known REAPER blob (Float32 only)
RenderFailed, // the render action ran but produced no output file RenderFailed, // the render action ran but produced no output file
TransportBusy, // realtime backend: transport already playing/recording — refused TransportBusy, // realtime backend: transport already playing/recording — refused
MultiTrackSelection,// a selected-tracks render over >1 track — would render N files MultiTrackSelection, // a selected-tracks render over >1 track — would render N files
BoundsMismatch, // the rendered file's frame count is not the requested window's BoundsMismatch, // the rendered file's frame count is not the requested window's
}; };
struct CaptureResult { struct CaptureResult {
@@ -130,7 +130,10 @@ CaptureName captureNameFor(const std::vector<std::string>& sourceNames,
// A Failed outcome is ALSO logged to the console here, because a successful capture's // A Failed outcome is ALSO logged to the console here, because a successful capture's
// CaptureResult::message is not printed by any caller — the return value alone would // CaptureResult::message is not printed by any caller — the return value alone would
// leave a genuine I/O failure indistinguishable from a legitimately stereo capture. // leave a genuine I/O failure indistinguishable from a legitimately stereo capture.
MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath); // `consoleLabel` matches each caller's own console-prefix convention (offline:
// "ReaSampler capture"; realtime: "ReaSampler realtime capture").
MonoCollapseOutcome collapseCapturedFileToMono(const std::string& absolutePath,
const char* consoleLabel = "ReaSampler capture");
// Stamps the metadata shared by both backends onto `s`: trackGuids (echoed from the // Stamps the metadata shared by both backends onto `s`: trackGuids (echoed from the
// request) + channelCount (measured from the produced file's `fmt`; 0/unknown as the // request) + channelCount (measured from the produced file's `fmt`; 0/unknown as the
@@ -186,7 +186,8 @@ CaptureResult finalizeRecording(ReaProject* proj, MediaTrack* temp,
// Channel-domain rewrite, after the frame-domain trim so it acts on the final // Channel-domain rewrite, after the frame-domain trim so it acts on the final
// frame set; it preserves the frame count, so the trimmed length above still holds. // frame set; it preserves the frame count, so the trimmed length above still holds.
const MonoCollapseOutcome collapseOutcome = collapseCapturedFileToMono(destPath); const MonoCollapseOutcome collapseOutcome =
collapseCapturedFileToMono(destPath, "ReaSampler realtime capture");
// Pure recorded-capture -> Sample mapping (identity, bounds echo, tier). // Pure recorded-capture -> Sample mapping (identity, bounds echo, tier).
RecordedCapture cap; RecordedCapture cap;
+2 -1
View File
@@ -32,7 +32,8 @@
// it never sums back into the master — no feedback, no monitoring double). // it never sums back into the master — no feedback, no monitoring double).
// Multiple selected tracks sum in the one temp track — a real mix, which is why // Multiple selected tracks sum in the one temp track — a real mix, which is why
// realtime accepts a multi-track selection where the offline track scope refuses // realtime accepts a multi-track selection where the offline track scope refuses
// it (that render source cannot express a sum; see shell/capture/CLAUDE.md). // it (the offline render source is read as emitting one file per track, DAW-
// unverified — see src/shell/capture/CLAUDE.md §Gotchas).
// //
// Why this needs no FxBypassGuard: CreateTrackSend defaults to I_SENDMODE=0 // Why this needs no FxBypassGuard: CreateTrackSend defaults to I_SENDMODE=0
// (post-fader), which taps the source track after its own FX/fader/pan — its // (post-fader), which taps the source track after its own FX/fader/pan — its
+17
View File
@@ -297,6 +297,22 @@ static void testRefusalMessagesAreSiblingsWithDistinctExits() {
CHECK(track.find("selected items") == std::string::npos); CHECK(track.find("selected items") == std::string::npos);
} }
// Golden literals: docs/verify-track-scope-multitrack.md §2 quotes the track message as
// an exact console match. A substring check alone leaves that doc free to drift from
// whatever ships, so pin both strings byte-for-byte here.
static void testRefusalMessagesMatchGoldenLiterals() {
CHECK(multiTrackRefusalMessage(CaptureScope::Item) ==
"This range is narrower than the selected items, so it renders "
"through their tracks -- and those items span more than one track, "
"which this shape cannot land as a single file. Capture one track's "
"items at a time, or make the range match the items' extent.");
CHECK(multiTrackRefusalMessage(CaptureScope::Track) ==
"A track capture renders the selected tracks through the master, "
"and more than one track cannot land as a single file. Capture one "
"track at a time, or route them into a folder/bus track and capture "
"that (a folder's own output is its children summed).");
}
// --- inferRangeSource: razor-else-time (orthogonal to scope) ----------------- // --- inferRangeSource: razor-else-time (orthogonal to scope) -----------------
static void testRangeInference() { static void testRangeInference() {
@@ -396,6 +412,7 @@ int main() {
testRangedItemScopeRendersTimeBounded(); testRangedItemScopeRendersTimeBounded();
testMultiTrackStemRenderIsNamedForRefusal(); testMultiTrackStemRenderIsNamedForRefusal();
testRefusalMessagesAreSiblingsWithDistinctExits(); testRefusalMessagesAreSiblingsWithDistinctExits();
testRefusalMessagesMatchGoldenLiterals();
testRangeInference(); testRangeInference();
testItemScopeBypassesEverythingButTake(); testItemScopeBypassesEverythingButTake();
testTrackScopeKeepsSelfBypassesAncestorsAndMaster(); testTrackScopeKeepsSelfBypassesAncestorsAndMaster();
+4 -2
View File
@@ -779,7 +779,9 @@ static void testCollapsePreservesQuietNaNBitPattern() {
// identically to "the channels differ" — a stereo file landing under a plain Ok. The // identically to "the channels differ" — a stereo file landing under a plain Ok. The
// report is where they must part: Declined stays silent (a capture with nothing to // report is where they must part: Declined stays silent (a capture with nothing to
// collapse reads as it always did), and the other two say different things. // collapse reads as it always did), and the other two say different things.
static void testCollapseOutcomesReportDistinctly() { // Pins the three suffix STRINGS, not user-observable behavior — see wav_codec.h's
// monoCollapseSuffix doc: this text reaches no one on a successful capture.
static void testCollapseOutcomeSuffixesAreDistinctStrings() {
const std::string declined = monoCollapseSuffix(MonoCollapseOutcome::Declined); const std::string declined = monoCollapseSuffix(MonoCollapseOutcome::Declined);
const std::string collapsed = monoCollapseSuffix(MonoCollapseOutcome::Collapsed); const std::string collapsed = monoCollapseSuffix(MonoCollapseOutcome::Collapsed);
const std::string failed = monoCollapseSuffix(MonoCollapseOutcome::Failed); const std::string failed = monoCollapseSuffix(MonoCollapseOutcome::Failed);
@@ -828,7 +830,7 @@ int main() {
testCollapseDeclinesOnUnparseableBytes(); testCollapseDeclinesOnUnparseableBytes();
testCollapseChangesContentHash(); testCollapseChangesContentHash();
testCollapsePreservesQuietNaNBitPattern(); testCollapsePreservesQuietNaNBitPattern();
testCollapseOutcomesReportDistinctly(); testCollapseOutcomeSuffixesAreDistinctStrings();
if (g_fail == 0) std::printf("wav_codec: all tests passed\n"); if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail); else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);