Files
reasampler/src/app/main.cpp
T
daniel e423bf6502 Buffer undo trace to a ring, add dump action, stop per-tick loadSaveProj spam
Trace lines fill a 4096-line ring instead of hitting the console per line;
DUMP_UNDO_TRACE drains it in one ShowConsoleMsg call. Overflow is marked, not silent.
2026-08-09 13:16:05 -04:00

454 lines
25 KiB
C++

// main.cpp — the SINGLE translation unit that OWNS the REAPER API pointers.
//
// REAPER dlopen()s reaper_*.dll|dylib|so from UserPlugins/ and calls the exported
// ReaperPluginEntry, handing over `rec` (rec->GetFunc resolves API pointers,
// rec->Register plugs our callbacks in). Exactly ONE .cpp defines
// REAPERAPI_IMPLEMENT (this one) — that allocates storage for the global API
// pointers every other TU gets `extern`. Never let a second TU define it.
//
// This TU is ONLY pointers + entry + dispatch. Its own action family registers
// through the data-driven table below (buildMainActionTable + action_registry) —
// adding a bindable action means adding ONE row and its handler function (OCP). The
// design_view / bank / ingest families keep their own register/handle/unregister
// triples, called from entry.
#define REAPERAPI_IMPLEMENT
#include "reaper_plugin.h"
#include "reaper_plugin_functions.h"
#include <cstddef>
#include <string>
#include <vector>
#include "core/capture/render_settings.h" // captureActionTable
#include "core/version/app_version.h" // appVersion
#include "shell/actions/ingest.h"
#include "shell/actions/action_registry.h" // the registration table
#include "shell/actions/bank_actions.h" // multi-bank action family
#include "shell/actions/design_view_actions.h" // Design View action family
#include "shell/actions/insert_fx_action.h" // insert-as-FX action body
#include "shell/actions/package_export_action.h" // bank-package export action body
#include "shell/actions/package_import_action.h" // bank-package import action body
#include "core/wire/bake_wire.h" // kBakeActionSuffix (the shared action id)
#include "shell/capture/bake_land.h" // resample-bake landing action body
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
#include "shell/capture/capture_orchestrator.h" // single-capture / realtime / insert action bodies
#include "shell/capture/realtime_lifecycle.h" // in-flight realtime state + tick driver
#include "shell/capture/render_in_place.h" // render-in-place action body
#include "shell/panel/panel_input.h" // bankPanelRefresh / bankPanelNotifyProjectLoaded
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
#include "shell/persist/session.h" // ReaSamplerSession
#include "shell/undo/undo_trace.h" // TEMPORARY — see undo_trace.h; revert together
#include "shell/view/fx_reclaim.h" // the FX reclaimer's tick
#include "shell/view/view.h" // applyMode
#include "shell/view/view_lanes.h" // reconcileManagedLanes
#include "shell/view/view_reconcile.h" // reconcileAppliedFlags
namespace capture = reasampler::capture;
// Globals other files reference via `extern`.
REAPER_PLUGIN_HINSTANCE g_hInst = nullptr;
reaper_plugin_info_t* g_rec = nullptr;
// Retired command-id SUFFIXES: kept ONLY to mirror-unregister on unload so a user's
// stale keybindings are cleaned up. Never re-register these. The four-mode WET ids,
// the removed master scope/realtime actions, and the removed per-action tail variants
// (tail is now a panel toggle, not a paired action).
static const char* const kRetiredCaptureCmdSuffixes[] = {
"CAPTURE_TRACKS_WET",
"CAPTURE_ITEMS_WET",
"CAPTURE_RAZOR_WET",
"CAPTURE_MASTER",
"CAPTURE_MASTER_REALTIME",
"CAPTURE_ITEM_TAIL",
"CAPTURE_TRACK_TAIL",
};
// Owns the in-memory BankModel and bridges it to project ext state. A timer tick
// drives g_session.poll() to detect project load / Save-As; capture adds Samples to
// g_session.bank() (resolves to the active bank's index), and we serialize the book
// back into the active project's ext state (the `banks` key) so it travels with the .rpp.
static reasampler::ReaSamplerSession g_session;
// Command id of the TOGGLE_BANK_PANEL row, resolved from the table once at load so
// OnToggleAction's checked-state poll is a single int compare (no per-poll lookup).
static int g_cmdToggleBankPanel = 0;
// Each handler is a thin stateless routing shim: (session, per-row arg) -> the
// action body in shell/capture/ or shell/panel/, existing only so table rows can be
// plain data with flat function pointers.
// `arg` is the captureActionTable() row index — the table rows below are built by
// iterating that pure taxonomy, so the routing stays 1:1 by construction.
static void RunCaptureScopeRow(int arg) {
capture::RunCapture(g_session,
capture::captureActionTable()[static_cast<std::size_t>(arg)]);
}
static void RunToggleBankPanel(int) { reasampler::bankPanelToggle(); }
static void RunCaptureItemAssign(int) { capture::RunCaptureItemAssign(g_session); }
// `arg` != 0 is the EXPLICIT conform-to-project-tempo opt-in (never silent); 0
// inserts at native length.
static void RunInsertSelected(int arg) {
capture::RunInsertSelected(g_session, arg != 0);
}
// Placement of the PLAYER, not of an item: adds a ReaSampler 9000 to the selected track's
// FX chain. Sessionless — it reads the panel's selection, exactly like the drag it mirrors.
static void RunInsertAsFx(int) { reasampler::doInsertAsFx(); }
static void RunBatchCaptureItems(int) { capture::RunBatchCaptureItems(g_session); }
static void RunBatchCaptureRazor(int) { capture::RunBatchCaptureRazor(g_session); }
static void RunCaptureRealtime(int) { capture::RunCaptureRealtimeTrack(g_session); }
static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); }
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
static void RunExportBankPackage(int) { reasampler::doBankPackageExport(g_session, g_session.book().activeBankId()); }
static void RunImportBankPackage(int) { reasampler::doImportBankPackage(g_session); }
static void RunShowVersion(int) {
// On-demand only — no unconditional startup print (routine console chatter pops
// the console window).
ShowConsoleMsg(("ReaSampler " + reasampler::version::appVersion() + "\n").c_str());
}
// TEMPORARY: drains the undo-trace ring buffer to the console in one call. See
// undo_trace.h; revert together with the rest of the undo-span tracing pass.
static void RunDumpUndoTrace(int) { reasampler::undoTraceDump(); }
// ONE row per bindable action this TU owns: FOREVER-STABLE id suffix, Actions-list
// phrase, handler, per-row arg. Registration, hookcommand dispatch, and the unload
// mirror-unregister all iterate this data. The capture scope rows come first,
// sourced from the pure captureActionTable() taxonomy; the rest are this TU's singles.
static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
using reasampler::ActionTableRow;
std::vector<ActionTableRow> rows;
const auto& cap = capture::captureActionTable();
for (std::size_t i = 0; i < cap.size(); ++i)
rows.push_back(ActionTableRow{cap[i].commandSuffix, cap[i].descriptionPhrase,
&RunCaptureScopeRow, static_cast<int>(i)});
// Show/hide the docked bank panel (display-only; never captures/inserts).
rows.push_back({"TOGGLE_BANK_PANEL", "toggle bank panel", &RunToggleBankPanel});
rows.push_back({"CAPTURE_ITEM_ASSIGN",
"capture selected item into bank + assign to active instance",
&RunCaptureItemAssign});
// Two variants differing ONLY in InsertOptions — native length vs conform opt-in.
rows.push_back({"INSERT_SELECTED", "insert selected sample at edit cursor",
&RunInsertSelected, 0});
rows.push_back({"INSERT_SELECTED_CONFORM",
"insert selected sample at edit cursor (conform to tempo)",
&RunInsertSelected, 1});
// A LOAD_*, not an INSERT_*: INSERT_SELECTED/_CONFORM place a timeline item;
// this loads an instrument onto a track's FX chain. The id is permanent, so
// keeping the two apart here is the most durable statement of which verb this is.
rows.push_back({"LOAD_INSTRUMENT_ON_TRACK",
"insert selected sample as ReaSampler 9000 on the selected track",
&RunInsertAsFx});
// One action fires N captures (per selected item / per razor area); the original
// selection is restored on every exit path. Bank-only, never places.
rows.push_back({"CAPTURE_BATCH_ITEMS",
"batch capture selected items (one per item)",
&RunBatchCaptureItems});
rows.push_back({"CAPTURE_BATCH_RAZOR", "batch capture razor areas (one per area)",
&RunBatchCaptureRazor});
// Realtime sibling of the offline CAPTURE_TRACK scope, plus its cancel-in-flight
// companion (stop + restore, non-destructive).
rows.push_back({"CAPTURE_TRACK_REALTIME", "capture selected track (realtime)",
&RunCaptureRealtime});
rows.push_back({"CANCEL_REALTIME_CAPTURE", "cancel realtime capture",
&RunCancelRealtime});
rows.push_back({"RECAPTURE_FROM_SOURCE", "re-capture from source",
&RunRecaptureFromSource});
// A RENDER_*, not a CAPTURE_*: the id is permanent and is the most durable
// statement the codebase makes about which pillar a feature belongs to.
rows.push_back({"RENDER_TRACK_IN_PLACE",
"render selected track to a new track (source moves to Design)",
&RunRenderTrackInPlace});
// Invoked by a ReaSampler 9000 instance over the VST3 host bridge (and bindable, so a
// stranded request can be landed by hand). The suffix is the wire contract itself —
// core/wire/bake_wire owns the spelling both artifacts read.
rows.push_back({reasampler::wire::kBakeActionSuffix,
"land pending ReaSampler 9000 resample bake",
&RunResampleBake});
rows.push_back({"EXPORT_BANK_PACKAGE", "export active bank as package",
&RunExportBankPackage});
rows.push_back({"IMPORT_BANK_PACKAGE", "import bank package (.rsbank)",
&RunImportBankPackage});
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
// TEMPORARY: see undo_trace.h; revert together with the rest of the undo-span
// tracing pass.
rows.push_back({"DUMP_UNDO_TRACE", "DIAG: dump undo trace", &RunDumpUndoTrace});
return rows;
}
// The timer callback REAPER runs periodically (registered via "timer"). It only
// forwards to the session poll — cheap per tick (reads the active project id and
// its .rpp path, acts only on a change).
static void OnTimer()
{
// Advance any in-flight realtime capture FIRST, so a project switch is caught and
// torn down/restored before session.poll() reacts to that switch. LOAD-BEARING:
// the idle fast-path is a SINGLE POINTER TEST — drive only when a capture is live.
if (capture::g_rtCapture) capture::DriveRealtimeCapture(g_session);
// TEMPORARY: captured in a local (rather than calling GetCurrentProjectInLoadSave()
// twice) so the trace can report it without a second call, with no change to the
// call below. See undo_trace.h. Traced only on CHANGE — every tick reads this
// regardless of state, and the transition is the whole point of the line.
ReaProject* const loadSaveProj = GetCurrentProjectInLoadSave();
static ReaProject* s_tracedLoadSaveProj = nullptr;
if (loadSaveProj != s_tracedLoadSaveProj) {
REASAMPLER_TRACE("[Timer] loadSaveProj=%p", (void*)loadSaveProj);
s_tracedLoadSaveProj = loadSaveProj;
}
// A project load or save is IN FLIGHT, so this tick is running inside its own message
// pump — a restore brings plugins online and a plugin load pumps. Everything below
// would then act on a half-restored project: poll() reads the pre-load <EXTSTATE> that
// OnBeginLoadProjectState defers past precisely because it is wrong, and the reclaim
// tick writes FX into a project REAPER is still rewriting. The extension's other
// re-entrancy guards cover OUR pumps; this one covers REAPER's. Consumes nothing, so
// the first tick after the load runs as if this one had never been due. Self-lowering
// (reaper_plugin_functions.h:1584-1587), and placed after the realtime drive so that
// fast path keeps its single-pointer-test shape. See src/shell/view/CLAUDE.md.
if (loadSaveProj) {
REASAMPLER_TRACE("[Timer] STANDOFF guard=loadSaveProj");
return;
}
// poll()'s undo/redo-reload and project-switch Load branches call
// loadFromProject, replacing g_session.view() wholesale — deferred here while
// an applyMode is on the stack (reachable if a pumped message loop re-enters
// this timer mid-apply), same retry-next-tick shape as the consume guard below.
// Deferred WHOLE, not just the reload branches: poll()'s identity tracking is
// cheap to skip for one tick and resumes correctly, unlatched, on the next.
// The reclaim drain is latched for the same reason and is the same window: it
// holds that view model for the length of a plugin unload, which pumps.
if (!reasampler::modeApplyInProgress() && !reasampler::fxReclaimDrainInProgress()) {
g_session.poll();
} else {
REASAMPLER_TRACE("[Timer] SKIP-POLL modeApply=%d drain=%d",
reasampler::modeApplyInProgress() ? 1 : 0,
reasampler::fxReclaimDrainInProgress() ? 1 : 0);
}
// persist stays MODEL-ONLY (loads the saved view model but does not apply
// visibility, to avoid coupling persist to the view shell); poll() raises a
// one-shot load signal that we drain here to reapply the SAVED active mode so a
// project saved in Design mode parks Arrange tracks automatically. The same
// signal re-arms the bank panel's new-content detector — notified BEFORE the
// reapply so re-arm and model restore ride the one load event (otherwise
// pre-existing tracks can be mis-detected as "new" and mass-tagged).
// Both latches are tested BEFORE the consume, not after the applyMode call: the load
// signal is one-shot, a re-entered applyMode fails closed, and a reapply dropped here
// is the saved mode never applied at all. Holding the whole block back one tick also
// keeps the panel re-arm ahead of the reapply.
// TEMPORARY: split into locals + if/else-if so the skip can be traced without
// calling consumeLoadSignal() (which has a side effect) a second time — same
// short-circuit semantics as the original single `if`. See undo_trace.h.
const bool loadGateModeApply = reasampler::modeApplyInProgress();
const bool loadGateDraining = reasampler::fxReclaimDrainInProgress();
if (loadGateModeApply || loadGateDraining) {
REASAMPLER_TRACE("[Timer] SKIP-LOAD modeApply=%d drain=%d", loadGateModeApply ? 1 : 0,
loadGateDraining ? 1 : 0);
} else if (g_session.consumeLoadSignal()) {
reasampler::bankPanelNotifyProjectLoaded();
// Reconcile lane ownership against the live project's lanes (P_LANENAME,
// the cross-session source of truth) BEFORE reapplying visibility. Never
// re-mints, never mass-tags.
reasampler::reconcileManagedLanes(g_session.view(), nullptr);
// An undo/redo reload also arrives here, and REAPER's undo does not restore
// the visibility flags a park drove (UNDO_STATE_TRACKCFG does not carry them).
// Repair the tracks the reloaded model no longer records BEFORE the reapply,
// which only ever drives tracks the model still records.
reasampler::reconcileAppliedFlags(g_session.view(), nullptr);
reasampler::applyMode(g_session.view(), g_session.view().activeModeId(), nullptr);
}
// Converge the FX chains toward what the view state says they should be — the
// 1897ms of TrackFX_SetOffline a mode switch no longer pays synchronously. Same
// idle discipline as the realtime fast path above: ONE bool read when there is
// nothing to converge. Ordered AFTER the load block deliberately — a reload replaces
// the view model wholesale, and the applyMode above is what re-derives the queue
// from it, so draining first would spend a tick against the outgoing model.
if (reasampler::fxReclaimPending())
reasampler::fxReclaimTick(g_session, nullptr);
reasampler::bankPanelRefresh(); // cheap fingerprint compare; no-op when unchanged/closed
}
// A Ctrl-Z/Ctrl-Shift-Z rolls back/forward the "reasampler" project ext state on disk
// but keeps the SAME project identity, so the timer's identity poll never re-reads
// ext state on undo/redo — the in-memory book/view would stay stale until
// close+reopen. REAPER's projectconfig fires BeginLoadProjectState on every
// project-state (re)load INCLUDING undo/redo (isUndo == true for both); we hook it.
//
// TIMING: BeginLoadProjectState fires BEFORE any state restore, so reading
// GetProjExtState here would return the PRE-undo value. Instead we raise a one-shot
// reload request that OnTimer's poll() drains on the NEXT tick, once REAPER has
// finished restoring the <EXTSTATE> block. Only the RELOAD REQUEST is undo-only: a
// normal open's reload belongs to the timer's identity-transition Load path, whose
// forked-sibling re-GUID branch requesting a reload here would bypass.
static void OnBeginLoadProjectState(bool isUndo, project_config_extension_t* /*reg*/)
{
REASAMPLER_TRACE("[Load] BeginLoadProjectState isUndo=%d", isUndo ? 1 : 0);
// UNBRANCHED, and the reason the terminator is a hook at all: this is REAPER's one
// entry for a state load — "project load/undo", plus a new project
// (reaper_plugin.h:1203-1205) — and the last moment before the restore. Why that
// matters, and why isUndo does not narrow it: fxReclaimAbandonForStateLoad.
reasampler::fxReclaimAbandonForStateLoad();
if (isUndo)
g_session.requestReload();
}
// Intentional no-op: ReaSampler stores state via project EXT STATE, not this
// extension's own project lines.
static bool OnProcessExtensionLine(const char* /*line*/, ProjectStateContext* /*ctx*/,
bool /*isUndo*/, project_config_extension_t* /*reg*/)
{
return false; // we own no project lines — ext state carries our data
}
// We write no lines into `ctx` — this is the FX reclaimer's one durability point. Its
// spent park records ride the user's save rather than dirtying the project on the
// reclaimer's own initiative (fx_reclaim.h's ReleaseFlush).
//
// A save only: an isUndo pass is REAPER capturing an undo point, not writing the file,
// and clearing the pending flush on one would leave the record unspent on disk for good.
static void OnSaveExtensionConfig(ProjectStateContext* /*ctx*/, bool isUndo,
project_config_extension_t* /*reg*/)
{
if (!isUndo)
reasampler::fxReclaimFlushRelease(g_session);
}
// Storage must outlive registration — REAPER holds this pointer until we unregister it.
static project_config_extension_t g_projectConfig{
&OnProcessExtensionLine,
&OnSaveExtensionConfig,
&OnBeginLoadProjectState,
nullptr, // userData
};
// REAPER calls this for every action fired in the MAIN section; claim only our own id,
// return false otherwise so REAPER keeps looking. This TU's own family dispatches through
// the registration table; the other families claim their own ids after it.
static bool OnHookCommand(int command, int /*flag*/)
{
if (command == 0) return false;
if (reasampler::actionTableHandleCommand(command)) return true;
if (reasampler::designViewHandleCommand(command)) return true;
if (reasampler::bankHandleCommand(command)) return true;
if (reasampler::ingestHandleCommand(command)) return true;
return false;
}
// "hookcommand" covers the main section only, so actions we published into another
// section arrive here instead. Partitioning contract: root `CLAUDE.md` §"REAPER
// extension contract".
static bool OnHookCommand2(KbdSectionInfo* /*sec*/, int command, int /*val*/, int /*val2*/,
int /*relmode*/, HWND /*hwnd*/)
{
if (command == 0) return false;
return reasampler::ingestHandleSectionCommand(command);
}
// REAPER polls this to render each of OUR actions' checked state in menus/toolbars.
// Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract).
static int OnToggleAction(int command)
{
if (command != 0 && command == g_cmdToggleBankPanel)
return reasampler::bankPanelIsOpen() ? 1 : 0;
return -1; // not ours / non-toggling
}
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
{
if (!rec)
{
// rec == nullptr => REAPER is UNLOADING us.
if (g_rec)
{
// Abort any in-flight realtime capture FIRST, while the API pointers are
// still live, so we never leave a temp track, an armed track, or an
// altered transport/cursor in the user's project on unload.
capture::AbortRealtimeCaptureForUnload(g_session);
// Same reason, for the reclaimer's undo span: a block left open here has no
// owner left to close it and would swallow every edit the user makes after.
reasampler::fxReclaimEndUndoSpan("extension unload");
g_rec->Register("-timer", (void*)&OnTimer);
g_rec->Register("-projectconfig", (void*)&g_projectConfig);
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
g_rec->Register("-hookcommand2", (void*)&OnHookCommand2);
reasampler::designViewUnregisterActions(g_rec);
reasampler::bankUnregisterActions(g_rec);
reasampler::ingestUnregisterActions(g_rec);
// This TU's own family, reverse table order; each '-command_id'
// re-presents the SAME interned pointer used at register.
reasampler::unregisterActionTable(g_rec);
// Retire the REMOVED command ids (command_id only — we never held a gaccel
// for them this session).
for (const char* suffix : kRetiredCaptureCmdSuffixes)
g_rec->Register("-command_id", (void*)reasampler::channelIdFor(suffix));
}
// Before dropping the API pointers: DockWindowRemove/DestroyWindow need them live.
reasampler::bankPanelShutdown();
g_rec = nullptr;
return 0;
}
// ABI guard: the struct layout we compiled against must match this REAPER.
if (rec->caller_version != REAPER_PLUGIN_VERSION)
return 0;
// Resolve every REAPER API function pointer. Returns the number that FAILED
// to load; 0 == success. Non-zero usually means REAPER is older than our SDK.
if (REAPERAPI_LoadAPI(rec->GetFunc) != 0)
return 0;
g_hInst = hInstance;
g_rec = rec;
// Point the bank panel at the live session BEFORE registering its action, so a
// toggle firing immediately has a session to read. Does not open the window.
reasampler::bankPanelInit(&g_session);
{
const std::vector<reasampler::ActionTableRow> rows = buildMainActionTable();
reasampler::registerActionTable(rec, rows.data(), rows.size());
}
// The panel toggle renders a checked state — resolve its minted id once and
// register the toggleaction hook that reports it.
g_cmdToggleBankPanel = reasampler::actionTableCommandId("TOGGLE_BANK_PANEL");
if (g_cmdToggleBankPanel)
rec->Register("toggleaction", (void*)&OnToggleAction);
// Each family mints its own command_id + gaccel, shares g_session, and is routed
// by the same hookcommand below. Registered before the hook so every id is
// minted first.
reasampler::designViewRegisterActions(rec, &g_session);
reasampler::bankRegisterActions(rec, &g_session);
reasampler::ingestRegisterActions(rec, &g_session);
rec->Register("hookcommand", (void*)&OnHookCommand);
rec->Register("hookcommand2", (void*)&OnHookCommand2);
// Drives project-load / Save-As detection: the timer polls the active project
// each tick; on a project load it reloads the bank from ext state, on a Save-As
// it relocates the bank folder under the new .rpp.
rec->Register("timer", (void*)&OnTimer);
// An UNDO/REDO state restore reloads the session's book + view from the restored
// ext state. The timer's identity poll cannot see an undo (same project
// identity), so this hook owns it (see OnBeginLoadProjectState).
rec->Register("projectconfig", (void*)&g_projectConfig);
return 1; // success — REAPER keeps us loaded
}