Files
reasampler/src/shell/capture/bake_land.cpp
T
daniel b7b93a7ac3 One undo-block opener for the whole tree: fourteen sites route through it, and the reclaim span can no longer swallow a user's act
The drain refuses a tick inside a foreign block; applyMode wakes the reclaimer
before it opens the span.
2026-08-07 16:38:11 -04:00

318 lines
16 KiB
C++

// 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) are verified against
// vendor/reaper-sdk/sdk/reaper_plugin_functions.h. Landing one request is
// bake_landing's; the undo block is shell/undo's.
#include "shell/capture/bake_land.h"
#include <cstdint>
#include <ctime>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#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 "ext_keys.h"
#include "shell/capture/bake_landing.h"
#include "shell/panel/panel_input.h" // bankPanelRefresh
#include "shell/persist/session.h"
#include "shell/undo/undo_block.h" // the ONE undo-block opener
#include "reaper_plugin.h" // UNDO_STATE_MISCCFG
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_EnumProjExtState
#define REAPERAPI_WANT_GetProjExtState
#define REAPERAPI_WANT_SetProjExtState
#define REAPERAPI_WANT_ShowConsoleMsg
#include "reaper_plugin_functions.h"
namespace reasampler::capture {
namespace {
using wire::BakeOutcome;
using wire::BakeRequest;
using wire::BakeStatus;
// 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) {
std::vector<std::string> keys;
const std::string prefix = kProjExtBakeKeyPrefix;
char keyBuf[256];
for (int idx = 0;; ++idx) {
keyBuf[0] = '\0';
if (!EnumProjExtState(proj, kProjExtNamespace(), idx, keyBuf,
static_cast<int>(sizeof(keyBuf)), nullptr, 0))
break;
const std::string key(keyBuf);
if (key.compare(0, prefix.size(), prefix) == 0) keys.push_back(key);
}
return keys;
}
// Whole value, or the reason there isn't one. Absent and Overflow both leave the scan with
// nothing to decode — one verdict — but they are different faults to go fix, so the reason
// survives to the per-key console line rather than collapsing here.
struct KeyRead {
std::optional<std::string> value;
bool oversized = false; // the value never fit under the grow loop's ceiling
};
KeyRead readKey(ReaProject* proj, const std::string& key) {
const auto read = wire::readProjExtStateGrowing([&](char* buf, int cap) {
return GetProjExtState(proj, kProjExtNamespace(), key.c_str(), buf, cap);
});
KeyRead out;
out.oversized = read.status == wire::GrowingExtStateRead::Status::Overflow;
if (read.status == wire::GrowingExtStateRead::Status::Complete) out.value = read.value;
return out;
}
struct OpenProject {
ReaProject* proj = nullptr;
std::string dir; // the project's own directory; empty for a never-saved project
};
// Every open project tab. The request key lives in the INSTANCE's project, which is not
// necessarily the focused one, so the scan cannot assume the current tab holds it.
std::vector<OpenProject> openProjects() {
std::vector<OpenProject> out;
std::vector<char> buf(4096, '\0');
for (int idx = 0;; ++idx) {
buf[0] = '\0';
ReaProject* proj = EnumProjects(idx, buf.data(), static_cast<int>(buf.size()));
if (!proj) break;
out.push_back(OpenProject{proj, projectDirOfRpp(std::string(buf.data()))});
}
return out;
}
// One scanned key: what the pass decided about it, and what to write back after the undo
// block closes. The outcome is kept UNENCODED until then, because the pass's persist runs
// after the whole scan and an Ok the project never took must not be sent as one.
struct ScannedKey {
enum class Write { None, Clear, Answer };
ReaProject* proj = nullptr;
std::string key;
Write write = Write::None; // None = an Ignore verdict, nothing to write
BakeOutcome outcome; // Write::Answer only
wire::BakeKeyOutcome report;
};
// 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. Which proof a throw yields turns on whether SetProjExtState had run: before
// it, the write is KNOWN not to have been made (`Rejected`); after it, the write may well
// have landed and only the check failed (`Unknown`).
wire::BakeWriteProof writeBackOne(ScannedKey& entry, bool persisted) {
bool issued = false;
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 no bank write had been issued for it at all. Without one 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());
issued = true;
// Only re-reading THIS key can speak for it (wire::extStateWriteLanded owns why).
// An oversized read proves nothing either way — folding it into that predicate'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::extStateWriteLanded(value, back.value) ? wire::BakeWriteProof::Confirmed
: wire::BakeWriteProof::Rejected;
} catch (...) {
return issued ? wire::BakeWriteProof::Unknown : wire::BakeWriteProof::Rejected;
}
}
} // namespace
void RunResampleBake(ReaSamplerSession& session) {
const void* loaded = session.loadedProject();
// REAPER's CURRENT tab, which is all this is. The action was invoked with the asking
// instance's own project as `proj` — getReaperParent(3), documented as the parent
// project in reaper_vst3_interfaces.h, and already paired with an EnumProjects-derived
// enumeration by the shipped rsusage_* path prune's protection depends on. What is
// `[verify — DAW]` is only whether REAPER makes that `proj` CURRENT for the action's
// duration, so nothing below may call this "the tab the bake was fired against". The
// per-key verdict holds either way, and a request this pass cannot land is told why
// rather than silently ignored.
const void* active = EnumProjects(-1, nullptr, 0);
// `loaded == active` alone is enough: every OpenProject::proj enumerated below is a
// real, non-null tab, so an unloaded `loaded` (nullptr) can never equal one and
// matchesLoadedProject (below) falls out false regardless of this flag's value.
const wire::BakeScanContext scanContext{loaded == active};
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
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 ran and reported success
bool bookChanged = false; // some landing added or refreshed an entry
// A local, not a tally field: an entry counted here can still be re-answered as a
// failure by the write-back loop below, so this is a persist GATE and never a count
// anything reports.
bool anyLanded = false;
// 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
// exists to make impossible. UndoBlock is inside the try, so unwinding still closes it.
try {
UndoBlock undo(nullptr);
for (const OpenProject& open : openProjects()) {
++tally.tabsScanned;
const bool activeTab = static_cast<const void*>(open.proj) == active;
for (const std::string& key : pendingBakeKeys(open.proj)) {
++tally.keysFound;
if (activeTab) ++tally.activeTabKeys;
const KeyRead read = readKey(open.proj, key);
const std::optional<BakeRequest> request =
read.value ? wire::decodeBakeRequest(*read.value) : std::nullopt;
const wire::BakeScanKey scanKey{
read.value.has_value(), request.has_value(),
request ? request->generation : 0,
static_cast<const void*>(open.proj) == loaded};
const wire::BakeScanVerdict verdict =
wire::classifyBakeScan(scanContext, scanKey, nowSec);
ScannedKey entry;
entry.proj = open.proj;
entry.key = key;
entry.report.verdict = verdict;
entry.report.oversized = read.oversized;
// Leave it — the writing instance owns clearing its own key.
if (verdict == wire::BakeScanVerdict::IgnoreUnreadable) {
++tally.unreadable;
scanned.push_back(std::move(entry));
continue;
}
// `!request` is folded in here rather than dereferenced below: today
// classifyBakeScan filters every undecoded key into this verdict, but a
// future verdict that did not would otherwise reach *request unguarded.
if (verdict == wire::BakeScanVerdict::IgnoreNotARequest || !request) {
++tally.notARequest;
entry.report.verdict = wire::BakeScanVerdict::IgnoreNotARequest;
scanned.push_back(std::move(entry));
continue;
}
if (verdict == wire::BakeScanVerdict::ClearStale) {
++tally.staleCleared;
entry.write = ScannedKey::Write::Clear;
scanned.push_back(std::move(entry));
continue;
}
if (verdict == wire::BakeScanVerdict::RefuseWrongProject) {
entry.outcome =
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) anyLanded = true;
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 = wire::BakeLanding::Unpersisted;
entry.outcome = std::move(attempt.outcome);
}
++tally.answered; // QUEUED, not written — the write loop below judges that
entry.write = ScannedKey::Write::Answer;
scanned.push_back(std::move(entry));
}
}
if (anyLanded) {
// 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 && 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 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;
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;
}
}
// 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 (...) {
}
}
if (!console.empty()) ShowConsoleMsg(console.c_str());
if (bookChanged) bankPanelRefresh();
}
} // namespace reasampler::capture