Make a bake's Banked landing reachable only through the persist upgrade

The dedup path claimed it directly, so a key could be answered Ok pointing at an entry a failed persist never wrote. Write-back is now guarded and three-valued, and landing one request split out of the scan pass.
This commit is contained in:
2026-08-02 12:35:06 -04:00
parent 1800bd64c3
commit 0afb885987
10 changed files with 664 additions and 371 deletions
+96 -313
View File
@@ -1,31 +1,23 @@
// bake_land.cpp — see bake_land.h. main.cpp owns the API pointers; this TU gets them
// extern. REAPER symbols used here (EnumProjects, EnumProjExtState, GetProjExtState,
// SetProjExtState, ShowConsoleMsg, Undo_BeginBlock2/EndBlock2) are verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h.
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h. Landing one request is bake_landing's.
#include "shell/capture/bake_land.h"
#include <cstdint>
#include <ctime>
#include <exception>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "core/capture/capture_paths.h" // deriveBankPaths / projectDirOfRpp
#include "core/capture/wav_codec.h" // parseWavLayout / hashWavContent
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/model/resample_name.h" // the iteration-chain display name
#include "core/tracking/tracking_authority.h" // resampleLanding (replace vs add-distinct)
#include "core/util/file_bytes.h" // shared whole-file loader
#include "core/capture/capture_paths.h" // projectDirOfRpp
#include "core/wire/bake_wire.h"
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop)
#include "core/wire/ext_state_read.h" // readProjExtStateGrowing (the shared grow loop)
#include "ext_keys.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/capture/bake_landing.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/persist/session.h"
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
@@ -44,256 +36,10 @@ namespace reasampler::capture {
namespace {
namespace fs = std::filesystem;
using model::AddResult;
using model::Sample;
using model::nextIterationName;
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
BakeOutcome refuse(BakeStatus status, std::string message, std::int64_t generation) {
BakeOutcome out;
out.status = status;
out.message = std::move(message);
out.generation = generation;
return out;
}
bool writeFileBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) return false;
f.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
return f.good();
}
// The bank holding `sampleId`, plus the entry itself. nullptr when no bank holds it.
const Sample* findSourceSample(const BankBook& book, const std::string& sampleId,
std::string& bankIdOut) {
for (const Bank& b : book.banks()) {
if (const Sample* s = b.index.query(sampleId)) {
bankIdOut = b.id;
return s;
}
}
return nullptr;
}
// Everything a landing DECIDES, before it touches disk or book: the staged WAV read and
// hash, the source lookup, replace-vs-add, and the dedup short-circuit. Split from the
// commit below so the two halves' throws can be told apart — a prepare that threw left the
// project untouched, and only a commit that threw may have written something.
struct PreparedLanding {
std::optional<BakeOutcome> settled; // a refusal or a dedup hit: nothing left to commit
BakeOutcome outcome; // the shape a successful commit completes
Sample landed; // the entry the commit indexes
std::vector<std::uint8_t> bytes; // the staged file, already read
std::string bankId;
std::string destDir;
std::string destPath;
bool replace = false;
};
PreparedLanding prepareLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
PreparedLanding prep;
if (projectDir.empty()) {
prep.settled = refuse(BakeStatus::NoProject,
"no saved project, so the bank has no location",
request.generation);
return prep;
}
prep.bytes = util::readFileBytes(request.stagedFilePath);
if (prep.bytes.empty()) {
prep.settled = refuse(BakeStatus::StagedMissing, "the staged render was unreadable",
request.generation);
return prep;
}
const WavLayout layout = parseWavLayout(prep.bytes);
if (!layout.valid || layout.frameCount() == 0) {
prep.settled = refuse(BakeStatus::StagedMissing,
"the staged render is not a usable WAV", request.generation);
return prep;
}
BankBook& book = session.book();
const Sample* source = findSourceSample(book, request.sourceSampleId, prep.bankId);
if (!source) {
prep.settled = refuse(BakeStatus::NoSource,
"the resampled capture is not in any bank", request.generation);
return prep;
}
// Copied, not aliased: every mutation the commit makes invalidates the book's pointers.
const Sample sourceCopy = *source;
const tracking::Landing landing = tracking::resampleLanding(
session.tiedUsageFor(request.sourceRelativePath, request.ownUsageKey));
prep.replace = (landing == tracking::Landing::Replace);
// WAV-aware hash: the bank's dedup key, and — on the add path only — the reason a
// byte-identical bake yields no second entry. Replace never dedups, matching
// updateSampleInPlace's own contract: an in-place refresh is not an insert.
const std::string contentHash = hashWavContent(prep.bytes);
prep.outcome.generation = request.generation;
prep.outcome.rootNote = request.rootNote;
prep.outcome.channelCount = static_cast<int>(layout.channelCount);
prep.outcome.replaced = prep.replace;
if (!prep.replace && !contentHash.empty()) {
if (const model::BankModel* index = book.index(prep.bankId)) {
if (const Sample* existing = index->findByHash(contentHash)) {
// Dedup BEFORE the disk write: this bake is bytes the bank already holds,
// so it re-points at that entry rather than depositing an unreferenced
// twin for the prune to reclaim later.
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = existing->id;
out.relativePath = existing->relativePath;
out.displayName = existing->displayName;
out.message = "identical to an existing capture -- pointed at it";
prep.settled = std::move(out);
return prep;
}
}
}
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
// Seconds alone are not unique enough here: two bakes of the same source inside one
// second would derive the same file name, and the second would overwrite a file the
// first had just indexed. The content hash separates them, and leaves a re-bake of
// identical bytes idempotent rather than duplicated.
const std::string uniqueTag =
std::to_string(nowSec) +
(contentHash.empty() ? std::string{} : "-" + contentHash.substr(0, 8));
const std::string stem =
sourceCopy.displayName.empty() ? std::string("resample") : sourceCopy.displayName;
const BankPaths paths = deriveBankPaths(projectDir, stem, uniqueTag);
prep.destDir = paths.absoluteDir;
prep.destPath = paths.absoluteDir + "/" + paths.fileName;
// Replace keeps the entry's identity and its slot — the sound iterated, it did not
// become a different capture. The superseded FILE is untouched: it stays on disk,
// unreferenced, until a prune reclaims it, which is the iterate loop's recovery floor.
prep.landed.id =
prep.replace ? sourceCopy.id : ("bake-" + uniqueTag + "-" + paths.fileName);
prep.landed.displayName =
prep.replace ? sourceCopy.displayName : nextIterationName(sourceCopy.displayName);
prep.landed.relativePath = paths.relativePath; // project-relative (invariant)
prep.landed.channelCount = static_cast<int>(layout.channelCount);
prep.landed.sampleRate = static_cast<int>(layout.sampleRate);
prep.landed.lengthSeconds =
layout.sampleRate ? static_cast<double>(layout.frameCount()) /
static_cast<double>(layout.sampleRate)
: 0.0;
// Rendered AT root — that is what makes it survive.
prep.landed.rootNote = request.rootNote;
prep.landed.tier = model::Tier::Scratch;
prep.landed.contentHash = contentHash;
prep.landed.createdTimestamp = nowSec;
// The lineage seed recordCreated reads: the ledger's parent chain is what makes a
// repeated bake readable as one iteration chain.
prep.landed.provenance = model::Provenance{sourceCopy.id, std::string{}};
return prep;
}
// The mutating half. From its first successful write a file exists and the book can change,
// so a throw out of THIS function is not invisible to the project — which is why its caller
// reports it differently from a prepare that threw. Its own refusals withdraw the file they
// wrote, best-effort. Mutates book + ledger without persisting; the caller persists once for
// the batch.
BakeOutcome commitLanding(ReaSamplerSession& session, PreparedLanding& prep) {
std::error_code ec;
fs::create_directories(prep.destDir, ec); // idempotent; the write reports failure
if (!writeFileBytes(prep.destPath, prep.bytes)) {
// A failed write can still have created the file before failing; withdraw it for
// the same reason the refused index below does.
fs::remove(prep.destPath, ec);
return refuse(BakeStatus::Failed, "could not write the bake into the bank folder",
prep.outcome.generation);
}
BankBook& book = session.book();
// On the replace path prepareLanding set `landed.id` to the source's own id, so this is
// the entry being refreshed in place.
const bool indexed =
prep.replace ? book.updateSampleInPlace(prep.landed.id, prep.landed)
: (book.index(prep.bankId) &&
book.index(prep.bankId)->add(prep.landed) == AddResult::Added);
if (!indexed) {
// Self-cleanup of a file this call wrote seconds ago and never indexed — the
// carve-out shell/persist/CLAUDE.md states, not prune's authority over the bank's
// known bytes. Leaving it would deposit an untracked orphan per refused bake.
fs::remove(prep.destPath, ec);
return refuse(BakeStatus::IndexRejected,
prep.replace ? "the bank refused the replacement"
: "the bank refused the new capture",
prep.outcome.generation);
}
session.recordCreated(prep.landed, tracking::OriginKind::Capture);
BakeOutcome out = prep.outcome;
out.status = BakeStatus::Ok;
out.sampleId = prep.landed.id;
out.relativePath = prep.landed.relativePath;
out.displayName = prep.landed.displayName;
out.message = prep.replace ? "replaced the bank entry" : "added as a distinct capture";
return out;
}
// One request landed, with the two failure truths kept apart: a throw out of the prepare
// half is a refusal that left the project untouched, while a throw out of the commit half
// may have written a file and changed the book — calling that one "refused" would tell the
// user nothing happened when something did.
struct LandingAttempt {
BakeOutcome outcome;
bool partial = false; // the commit threw: a file and/or an entry may exist
bool changedBook = false; // the book gained or refreshed an entry, unpersisted so far
};
LandingAttempt attemptLanding(ReaSamplerSession& session, const std::string& projectDir,
const BakeRequest& request) {
LandingAttempt attempt;
PreparedLanding prep;
try {
prep = prepareLanding(session, projectDir, request);
} catch (const std::exception& e) {
attempt.outcome =
refuse(BakeStatus::Failed,
std::string("the landing failed before it wrote anything: ") + e.what(),
request.generation);
return attempt;
} catch (...) {
attempt.outcome =
refuse(BakeStatus::Failed,
"the landing failed before it wrote anything, for an unknown reason",
request.generation);
return attempt;
}
if (prep.settled) {
attempt.outcome = std::move(*prep.settled);
return attempt; // a refusal, or a dedup hit that changed nothing
}
try {
attempt.outcome = commitLanding(session, prep);
attempt.changedBook = attempt.outcome.status == BakeStatus::Ok;
} catch (const std::exception& e) {
attempt.partial = true;
attempt.outcome = refuse(BakeStatus::Failed,
std::string("the landing failed while writing: ") + e.what(),
request.generation);
} catch (...) {
attempt.partial = true;
attempt.outcome = refuse(BakeStatus::Failed,
"the landing failed while writing, for an unknown reason",
request.generation);
}
return attempt;
}
// Every "rsbake_*" key in one project, keys only — the value can outgrow
// EnumProjExtState's fixed buffer, so it is read separately by the growing reader.
std::vector<std::string> pendingBakeKeys(ReaProject* proj) {
@@ -329,15 +75,6 @@ KeyRead readKey(ReaProject* proj, const std::string& key) {
return out;
}
// Writes one value under `key` and PROVES it by reading the key back. SetProjExtState's own
// return cannot do that: it is the size of the whole extname's state, which `banks` alone
// keeps non-zero in every case a bake can reach. One extra read per written key, on a cold
// path. An empty `value` is a clear.
bool writeKeyVerified(ReaProject* proj, const std::string& key, const std::string& value) {
SetProjExtState(proj, kProjExtNamespace(), key.c_str(), value.c_str());
return wire::bakeWriteLanded(value, readKey(proj, key).value);
}
struct OpenProject {
ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project
@@ -390,6 +127,50 @@ private:
int flags_ = 0;
};
// One entry's write-back, and the proof of whether it took. Guarded whole: it encodes into
// a fresh string and readKey grows a buffer toward 16 MB, and a throw escaping here would
// skip the ShowConsoleMsg for EVERY key — the silent console this action exists to make
// impossible. A throw is `Unknown` rather than a failure because the write may well have
// landed before the check did.
wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
try {
std::string value; // empty = clear the key
if (entry.write == ScannedKey::Write::Answer) {
// Where a landing earns the word: the pass's persist runs after the whole scan,
// so until here nothing had observed whether the project took it. Without it
// the entry lives in memory alone, which is a failed bake to anyone who
// reloads — so it is answered as one rather than as an Ok.
const wire::BakeLanding after =
wire::bakeLandingAfterPersist(entry.report.landing, persisted);
if (after == wire::BakeLanding::Unpersisted) {
entry.outcome = refuseBake(
BakeStatus::Failed,
"the bake reached the bank in memory, but this pass's persist did not "
"report success, so this answer cannot promise a reload will find it",
entry.outcome.generation);
// `detail` left empty on purpose: describeLanding's Unpersisted clause is
// that sentence's one home on the console side, and the message above is
// the instrument's own copy on the wire.
} else {
entry.report.detail = entry.outcome.message;
}
entry.report.landing = after;
value = wire::encodeBakeOutcome(entry.outcome);
}
SetProjExtState(entry.proj, kProjExtNamespace(), entry.key.c_str(), value.c_str());
// SetProjExtState's own return is the size of the whole extname's state, which
// `banks` alone keeps non-zero in every case a bake can reach, so only re-reading
// THIS key can speak for it. An oversized read proves nothing either way — folding
// it into bakeWriteLanded's absent case would report a clear off unread bytes.
const KeyRead back = readKey(entry.proj, entry.key);
if (back.oversized) return wire::BakeWriteProof::Unknown;
return wire::bakeWriteLanded(value, back.value) ? wire::BakeWriteProof::Confirmed
: wire::BakeWriteProof::Rejected;
} catch (...) {
return wire::BakeWriteProof::Unknown;
}
}
} // namespace
void RunResampleBake(ReaSamplerSession& session) {
@@ -412,7 +193,8 @@ void RunResampleBake(ReaSamplerSession& session) {
std::vector<ScannedKey> scanned;
wire::BakeScanTally tally; // every verdict below is counted, skips included
std::string aborted; // set only when the scan itself threw
bool persisted = false; // the pass's ONE persist actually happened
bool persisted = false; // the pass's ONE persist reported success
bool bookChanged = false; // some landing added or refreshed an entry
// The scan allocates outside attemptLanding's own guards too (readKey grows a buffer
// toward 16 MB, every container here allocates), and a throw that escaped would discard
// the answers already buffered — the exact no-answer-with-a-silent-console this action
@@ -465,25 +247,24 @@ void RunResampleBake(ReaSamplerSession& session) {
if (verdict == wire::BakeScanVerdict::RefuseWrongProject) {
entry.outcome =
refuse(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension has "
"loaded -- focus that tab and try again",
request->generation);
refuseBake(BakeStatus::WrongProject,
"this bake's project tab is not the one the extension "
"has loaded -- focus that tab and try again",
request->generation);
} else {
LandingAttempt attempt = attemptLanding(session, open.dir, *request);
if (attempt.outcome.status == BakeStatus::Ok) ++tally.landed;
// Unpersisted rather than Banked for anything that changed the book: the
// pass's persist has not run yet, and the write loop is where the
// upgrade is earned. A dedup hit changed nothing, so its landing is not
// this pass's to lose.
if (attempt.changedBook) bookChanged = true;
// Never Banked here, on ANY path: the pass's persist has not run yet,
// and bakeLandingAfterPersist is the one place that word is earned. A
// dedup hit is no exception — the entry it points at may be one this
// same pass added and is about to fail to persist.
if (attempt.partial)
entry.report.landing = wire::BakeLanding::Partial;
else if (attempt.outcome.status != BakeStatus::Ok)
entry.report.landing = wire::BakeLanding::Refused;
else
entry.report.landing = attempt.changedBook
? wire::BakeLanding::Unpersisted
: wire::BakeLanding::Banked;
entry.report.landing = wire::BakeLanding::Unpersisted;
entry.outcome = std::move(attempt.outcome);
}
++tally.answered; // QUEUED, not written — the write loop below judges that
@@ -493,56 +274,58 @@ void RunResampleBake(ReaSamplerSession& session) {
}
if (tally.landed > 0) {
// A bake changes what a live instance would play, so the generation bump rides
// the persist — every other open instance refreshes hands-free.
session.bumpBankGeneration();
// A dedup hit rides the persist too, because that is the only observation that
// can promote its landing — but it added nothing, so it earns neither the
// refresh every open instance pays for nor an undo point named after a bake
// that banked nothing.
if (bookChanged) session.bumpBankGeneration();
persisted = session.saveToActiveProject();
if (persisted)
if (persisted && bookChanged)
undo.recordPoint("ReaSampler: resample bake into bank", UNDO_STATE_MISCCFG);
}
} catch (...) {
aborted =
"ReaSampler resample: the landing pass stopped early on an internal failure. "
"The answers it had already prepared are written below; any key whose answer it "
"had not prepared yet went unanswered and is not named at all.\n";
"The answers it had already prepared are reported below; any key whose answer "
"it had not prepared yet went unanswered and is not named at all.\n";
}
// OUTSIDE the undo block on purpose: the answer is transient handshake state, and an
// undo point that captured it could restore the consumed request on the next Ctrl-Z —
// which the following bake would then re-land against a temp file that is long gone.
std::string console = aborted;
for (ScannedKey& entry : scanned) {
if (entry.write != ScannedKey::Write::None) {
std::string value; // empty = clear the key
if (entry.write == ScannedKey::Write::Answer) {
// Where a landing earns the word: the persist ran after the whole scan, so
// until here nothing had observed whether the project took it. Without it
// the entry lives in memory alone, which is a failed bake to anyone who
// reloads — so it is answered as one rather than as an Ok.
if (entry.report.landing == wire::BakeLanding::Unpersisted) {
if (persisted)
entry.report.landing = wire::BakeLanding::Banked;
else
entry.outcome = refuse(BakeStatus::Failed,
"the bake reached the bank in memory, but "
"this pass could not persist it, so the "
"project's saved bank state does not carry it",
entry.outcome.generation);
try {
for (ScannedKey& entry : scanned) {
if (entry.write != ScannedKey::Write::None) {
entry.report.proof = writeBackOne(entry, persisted);
if (entry.write == ScannedKey::Write::Answer) {
if (entry.report.proof == wire::BakeWriteProof::Rejected)
++tally.writeFailed;
else if (entry.report.proof == wire::BakeWriteProof::Unknown)
++tally.writeUnproven;
}
value = wire::encodeBakeOutcome(entry.outcome);
entry.report.detail = entry.outcome.message;
}
entry.report.writeConfirmed = writeKeyVerified(entry.proj, entry.key, value);
if (!entry.report.writeConfirmed && entry.write == ScannedKey::Write::Answer)
++tally.writeFailed;
// Unconditional, including on success: the tally counts keys but cannot name
// them, and the key name is the only thing that tells an instance which line is
// its own.
console += wire::describeBakeKey(entry.key, entry.report);
}
console += wire::describeBakeScan(tally);
} catch (...) {
// Whatever lines already accumulated must still reach the console — that guarantee
// is this action's whole reason for existing. A second throw while appending the
// note below is swallowed for exactly the same reason: the print is what matters,
// and append gives the strong guarantee, so `console` survives either way.
try {
console +=
"ReaSampler resample: the write-back pass stopped early on an internal "
"failure. The lines above are everything it managed to report; any key not "
"named above may or may not have been answered.\n";
} catch (...) {
}
// Unconditional, including on success: the tally counts keys but cannot name them,
// and the key name is the only thing that tells an instance which line is its own.
console += wire::describeBakeKey(entry.key, entry.report);
}
console += wire::describeBakeScan(tally);
if (!console.empty()) ShowConsoleMsg(console.c_str());
if (tally.landed > 0) bankPanelRefresh();
if (bookChanged) bankPanelRefresh();
}
} // namespace reasampler::capture