From f6dddbf5a36c7aa6aca9e686ef7fbf4aef33787b Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 15:16:55 -0400 Subject: [PATCH 1/4] feat(version): single-source semver, ext-state stamp, show-version action Version "0.9.01" sourced once from CMake via configure_file'd header; pure app_version module (parse/compare/classify) with CTest. Stamp rides the persist save seam under "reasampler"/"version"; absent stamp reads as pre-versioning. On-demand "show version" action, no startup print. --- CMakeLists.txt | 42 ++++++++++- src/app_version.cpp | 76 ++++++++++++++++++++ src/app_version.h | 73 ++++++++++++++++++++ src/main.cpp | 31 +++++++++ src/persist.cpp | 18 +++++ src/persist.h | 24 +++++++ src/version_generated.h.in | 9 +++ tests/test_app_version.cpp | 138 +++++++++++++++++++++++++++++++++++++ 8 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 src/app_version.cpp create mode 100644 src/app_version.h create mode 100644 src/version_generated.h.in create mode 100644 tests/test_app_version.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c565887..a1b5497 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,10 +1,30 @@ cmake_minimum_required(VERSION 3.19) -project(reaper_reasampler LANGUAGES CXX) + +# --------------------------------------------------------------------------- +# Version — SINGLE SOURCE OF TRUTH (Phase V, V1). Edit REASAMPLER_VERSION here and +# nowhere else: it flows to the binary constant, the ext-state writing-version stamp, +# and the "show version" action via a configure_file'd header (below). The string is +# authoritative verbatim — leading zero preserved (Daniel-fixed: exactly "0.9.01", +# two-digit zero-padded patch). We deliberately do NOT reconstruct the display string +# from project(VERSION)'s numeric components, since CMake may normalize a numeric patch +# field; the string variable is what renders. project(VERSION ...) is still set (with a +# normalized 0.9.1 triple) for CMake hygiene / any downstream numeric use, but it is NOT +# the rendered source of truth. +set(REASAMPLER_VERSION "0.9.01") + +project(reaper_reasampler VERSION 0.9.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) +# Generate version_generated.h from the one REASAMPLER_VERSION variable. Regenerated at +# configure time whenever the variable changes; the exact string is substituted verbatim. +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/src/version_generated.h.in + ${CMAKE_CURRENT_BINARY_DIR}/generated/version_generated.h + @ONLY) + # --------------------------------------------------------------------------- # Vendored dependencies — add as git submodules (see README): # git submodule add https://github.com/justinfrankel/reaper-sdk vendor/reaper-sdk @@ -195,6 +215,19 @@ add_library(wav_trim STATIC src/wav_trim.cpp) target_include_directories(wav_trim PUBLIC src) target_link_libraries(wav_trim PUBLIC peaks) +# --------------------------------------------------------------------------- +# 2i) Pure app_version library — NO REAPER, NO SWELL. The Phase V (V1) version-identity +# core: re-exports the ONE CMake-sourced version string (via the configure_file'd +# version_generated.h) and owns the pure parse/compare/classify logic — the semver +# ordering a within-channel forward migration needs, and the three-way writing-version +# classification (PreVersioning / Unknown / Stamped) persist reads back from ext state. +# Split out so the exact-string fidelity + parse/compare are unit-tested outside the +# DAW; the ext-state write/read (persist) and the show-version action (main) are shell. +# Depends on the generated header in the build tree (PUBLIC so every consumer sees it). +# --------------------------------------------------------------------------- +add_library(app_version STATIC src/app_version.cpp) +target_include_directories(app_version PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -267,6 +300,10 @@ add_executable(owned_manifest_tests tests/test_owned_manifest.cpp) target_link_libraries(owned_manifest_tests PRIVATE owned_manifest) add_test(NAME owned_manifest_tests COMMAND owned_manifest_tests) +add_executable(app_version_tests tests/test_app_version.cpp) +target_link_libraries(app_version_tests PRIVATE app_version) +add_test(NAME app_version_tests COMMAND app_version_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- @@ -303,8 +340,9 @@ add_library(reaper_reasampler MODULE src/actions.cpp src/bank_book.cpp src/owned_manifest.cpp + src/app_version.cpp ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest app_version) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") diff --git a/src/app_version.cpp b/src/app_version.cpp new file mode 100644 index 0000000..9a995c3 --- /dev/null +++ b/src/app_version.cpp @@ -0,0 +1,76 @@ +// app_version.cpp — implementation of the pure version-identity core (Phase V, V1). +// See app_version.h for the contract. The version STRING itself comes from +// version_generated.h (produced by CMake configure_file from the one REASAMPLER_VERSION +// variable) — this TU just re-exports it and owns the pure parse/compare/classify logic. + +#include "app_version.h" + +#include + +#include "version_generated.h" // REASAMPLER_VERSION_STRING — configure_file'd from CMake + +namespace reasampler { + +const std::string& appVersion() { + // Function-local static: initialized once from the compile-time string, returned by + // const ref so callers share the one authoritative instance. The macro is the exact + // CMake value, leading zero and all. + static const std::string kVersion = REASAMPLER_VERSION_STRING; + return kVersion; +} + +std::optional parseVersion(const std::string& s) { + // Split on '.' into exactly three non-empty all-digit components. No sign, no + // whitespace, no trailing garbage. Leading zeros are allowed (0.9.01 parses to + // {0,9,1}) — the display fidelity of the zero is the string's job, not this parse's. + Version v; + int component = 0; // 0=major, 1=minor, 2=patch + long long acc = 0; // current component accumulator (long long guards overflow) + bool digitsInComponent = false; + int* const out[3] = {&v.major, &v.minor, &v.patch}; + + for (char c : s) { + if (c == '.') { + if (!digitsInComponent) return std::nullopt; // empty component (".", "1..2") + if (component >= 2) return std::nullopt; // too many dots + *out[component] = static_cast(acc); + ++component; + acc = 0; + digitsInComponent = false; + continue; + } + if (c < '0' || c > '9') return std::nullopt; // non-digit (sign, letter, ws) + acc = acc * 10 + (c - '0'); + if (acc > 1'000'000'000LL) return std::nullopt; // absurdly large -> reject + digitsInComponent = true; + } + if (component != 2 || !digitsInComponent) return std::nullopt; // too few components + *out[2] = static_cast(acc); + return v; +} + +bool versionLess(const Version& a, const Version& b) { + if (a.major != b.major) return a.major < b.major; + if (a.minor != b.minor) return a.minor < b.minor; + return a.patch < b.patch; +} + +WritingVersion classifyWritingVersion(const std::string& rawStamp) { + WritingVersion wv; + if (rawStamp.empty()) { + wv.kind = WritingVersion::Kind::PreVersioning; // no stamp -> pre-versioning + return wv; + } + std::optional parsed = parseVersion(rawStamp); + if (!parsed) { + wv.kind = WritingVersion::Kind::Unknown; // present but malformed -> ignored + wv.raw = rawStamp; + return wv; + } + wv.kind = WritingVersion::Kind::Stamped; + wv.raw = rawStamp; + wv.parsed = *parsed; + return wv; +} + +} // namespace reasampler diff --git a/src/app_version.h b/src/app_version.h new file mode 100644 index 0000000..98ee101 --- /dev/null +++ b/src/app_version.h @@ -0,0 +1,73 @@ +#pragma once +// app_version — the REAPER-free version-identity core (Phase V, V1). The single +// source of truth for the version STRING lives in CMake (a `REASAMPLER_VERSION` +// variable threaded in via configure_file -> version_generated.h); this module +// re-exports it as the canonical constant and owns every pure operation on it: the +// exact-string render, the parse/compare arithmetic a within-channel forward +// migration will lean on, and the "which version wrote this project" result that +// persist reads back from ext state (absent stamp = pre-versioning, never an error). +// +// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library +// only. Builds and unit-tests without REAPER (mirror of bank_model / tail_control). +// +// Leading-zero fidelity (V1, Daniel-fixed): the displayed/stamped string is EXACTLY +// "0.9.01" — two-digit zero-padded patch. That exactness is why the version STRING is +// the authoritative artifact (sourced verbatim from the one CMake variable), not a +// reconstruction from numeric components — CMake's `project(VERSION)` may normalize a +// numeric patch field, so we never round-trip the string through integers to render it. + +#include +#include + +namespace reasampler { + +// The canonical version string — EXACTLY the value of the CMake `REASAMPLER_VERSION` +// variable (see version_generated.h, produced by configure_file). One edit point: +// changing that variable changes this constant, the ext-state stamp, and the +// show-version action output with no other edits. Leading zero preserved verbatim. +const std::string& appVersion(); + +// A parsed semver triple. Kept minimal — major.minor.patch as integers, for ORDERING +// only. It deliberately does NOT round-trip back to the display string (the leading +// zero is a rendering concern owned by the authoritative string, not reconstructable +// from the integer patch). parseVersion returns nullopt on malformed input. +struct Version { + int major = 0; + int minor = 0; + int patch = 0; +}; + +// Parse "x.y.z" (each component a non-negative integer, leading zeros allowed) into a +// Version. Returns nullopt on anything malformed: wrong component count, non-digits, a +// leading `-`, empty components, or trailing garbage. Used for ordering two stamps and +// for validating a stored stamp before comparing. +std::optional parseVersion(const std::string& s); + +// Numeric ordering by (major, minor, patch). a < b iff a precedes b. So +// 0.9.01 < 0.9.02 < 0.10.01 (numeric compare, NOT lexicographic — 10 > 9). +bool versionLess(const Version& a, const Version& b); + +// The writing-version a project was last saved with, recovered from its ext-state +// stamp. A project saved before this feature shipped has NO stamp — that is the +// explicit `preVersioning` case (kind), not an error and not a warning. A present-but- +// malformed value is `unknown` (also silent — a corrupt stamp is ignored, never +// throws). A well-formed value is `stamped` and carries the exact stored string plus +// its parsed triple for comparison. +struct WritingVersion { + enum class Kind { + PreVersioning, // no stamp stored — a project written before versioning shipped + Unknown, // a stamp was stored but is not parseable — ignored, not an error + Stamped, // a well-formed stamp + }; + Kind kind = Kind::PreVersioning; + std::string raw; // the exact stored string (empty for PreVersioning) + Version parsed; // meaningful only when kind == Stamped +}; + +// Classify a raw stored stamp value (exactly what GetProjExtState returned for the +// version key). Empty -> PreVersioning; non-empty but unparseable -> Unknown; parseable +// -> Stamped. Pure so the three-way classification is test-pinned; persist calls this +// with the raw ext-state read and never has to reason about the cases itself. +WritingVersion classifyWritingVersion(const std::string& rawStamp); + +} // namespace reasampler diff --git a/src/main.cpp b/src/main.cpp index 26c18a9..691b01f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -24,6 +24,7 @@ #include #include "actions.h" +#include "app_version.h" #include "bank_model.h" #include "bank_panel.h" #include "capture.h" @@ -109,6 +110,13 @@ static int g_cmdCaptureTrackRealtime = 0; // the transport-stop. No-op (with a note) when nothing is in flight. static int g_cmdCancelRealtime = 0; +// Command id for the Phase V "show version" action. FOREVER-STABLE string. On demand +// ONLY — prints the CMake-sourced version string to the console when fired. This is the +// SOLE new console output the versioning wave adds; there is no unconditional startup +// version print (routine console chatter was deliberately removed — it pops the console +// window). The user copies this line into a bug report. +static int g_cmdShowVersion = 0; + // The persistence session (M4): owns the in-memory BankIndex 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() — which (B2) resolves to @@ -771,6 +779,12 @@ static bool OnHookCommand(int command, int /*flag*/) if (command == g_cmdInsertSelectedConform) { RunInsertSelected(true); return true; } if (command == g_cmdCaptureTrackRealtime) { RunCaptureRealtimeTrack(); return true; } if (command == g_cmdCancelRealtime) { RunCancelRealtime(); return true; } + if (command == g_cmdShowVersion) + { + // On-demand version readout — the ONLY version output on any path. + ShowConsoleMsg(("ReaSampler " + reasampler::appVersion() + "\n").c_str()); + return true; + } // Design View action family (D4). Claims only its own ids; returns false for the // rest so this hook keeps looking (per the contract). if (reasampler::designViewHandleCommand(command)) return true; @@ -795,6 +809,7 @@ static gaccel_register_t g_accelInsertSelected{}; static gaccel_register_t g_accelInsertSelectedConform{}; static gaccel_register_t g_accelCaptureTrackRealtime{}; static gaccel_register_t g_accelCancelRealtime{}; +static gaccel_register_t g_accelShowVersion{}; extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec) @@ -826,6 +841,9 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( reasampler::designViewUnregisterActions(g_rec); // Tear down the multi-bank action family (B3) — same mirror-unregister. reasampler::bankUnregisterActions(g_rec); + g_rec->Register("-gaccel", (void*)&g_accelShowVersion); + g_rec->Register("-command_id", + (void*)(REASAMPLER_ACTION_PREFIX "SHOW_VERSION")); g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime); g_rec->Register("-command_id", (void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE")); @@ -969,6 +987,19 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( rec->Register("gaccel", (void*)&g_accelCancelRealtime); } + // Register the Phase V "show version" action (command_id -> gaccel -> hookcommand). + // On-demand only — prints the CMake-sourced version to the console when fired; no + // startup print. FOREVER-STABLE id string. + g_cmdShowVersion = rec->Register( + "command_id", + (void*)(REASAMPLER_ACTION_PREFIX "SHOW_VERSION")); + if (g_cmdShowVersion) + { + g_accelShowVersion.accel.cmd = g_cmdShowVersion; + g_accelShowVersion.desc = "ReaSampler: show version"; + rec->Register("gaccel", (void*)&g_accelShowVersion); + } + // Register the Design View action family (D4): toggle/activate mode, tag/untag/ // show-both selected tracks. Each mints its own command_id + gaccel; the single // hookcommand below routes them via designViewHandleCommand. Registered before diff --git a/src/persist.cpp b/src/persist.cpp index 3f49a08..b1d56cf 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -79,6 +79,7 @@ #include #include +#include "app_version.h" #include "capture_paths.h" #define REAPERAPI_MINIMAL @@ -226,6 +227,13 @@ bool ReaSamplerSession::saveToActiveProject() { SetProjExtState(static_cast(proj), kProjExtNamespace, kProjExtOwnedKey, ownedJson.c_str()); + // Phase V (V1): stamp the WRITING version — the build producing this save — under the + // version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty + // stay paired (no drifting ad-hoc SetProjExtState). appVersion() is the one CMake- + // sourced constant; every save records the exact current build into the .rpp. + SetProjExtState(static_cast(proj), kProjExtNamespace, + kProjExtVersionKey, appVersion().c_str()); + MarkProjectDirty(static_cast(proj)); return true; } @@ -315,6 +323,16 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // (R-B) must re-read the restored manifest so it matches the rolled-back bank state. owned_ = loadOwnedManifest(static_cast(proj)); + // Phase V (V1): recover the writing-version stamp on EVERY load path (peer-symmetry + // with tail_/view_ above). An absent stamp classifies as PreVersioning, a malformed + // one as Unknown — both silent, no console warning (a pre-versioning project is not + // an error). getProjExtStateString returns "" for an absent key, which is exactly the + // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default. + writingVersion_ = classifyWritingVersion( + proj ? getProjExtStateString(static_cast(proj), kProjExtNamespace, + kProjExtVersionKey) + : std::string{}); + if (!proj) { book_ = BankBook{}; return; diff --git a/src/persist.h b/src/persist.h index 75252fa..b61d03d 100644 --- a/src/persist.h +++ b/src/persist.h @@ -19,6 +19,7 @@ #include +#include "app_version.h" #include "bank_book.h" #include "bank_model.h" #include "owned_manifest.h" @@ -70,6 +71,14 @@ inline constexpr const char* kProjExtTailKey = "tail_setting"; // graceful, but the attribution safety net is lost until the next capture rebuilds it). inline constexpr const char* kProjExtOwnedKey = "owned_files"; +// The ext-state key holding the ReaSampler version that last WROTE this project +// (Phase V, V1). Written on every save alongside the banks/view/tail keys, so every +// saved .rpp records which build produced its state — the seam a future within-channel +// forward migration keys off ("this was written by 0.9.01, I am 0.9.05"). An absent +// key is the explicit pre-versioning case (a project saved before this shipped), read +// silently, never an error. FOREVER-STABLE key string once shipped. +inline constexpr const char* kProjExtVersionKey = "version"; + // The ext-state key holding a GUID we mint per project to establish CONTENT-BASED // project identity (REAPER exposes no stable per-project GUID). poll() uses it to // tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch @@ -143,6 +152,15 @@ public: OwnedFileManifest& owned() { return owned_; } const OwnedFileManifest& owned() const { return owned_; } + // The ReaSampler version that last WROTE the active project, recovered from its + // ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no + // stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the + // exact stored string otherwise — all silent, never an error. Replaced on every load + // path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved + // or never-loaded session. Exposed so a future migration step (or diagnostics) can + // reason about the origin build without re-reading ext state. + const WritingVersion& writingVersion() const { return writingVersion_; } + // Serialize the current book (under the `banks` key), view model, and tail setting // to the active project's ext state (namespace "reasampler"), and clear the retired // legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys. @@ -208,6 +226,12 @@ private: // the in-memory set matches disk. Absent key -> empty is graceful (older project). OwnedFileManifest owned_; + // The writing-version stamp recovered on load (Phase V). Default PreVersioning; + // loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so + // switching to a pre-versioning project reports PreVersioning rather than inheriting + // the previous project's stamp. Read-only to consumers via writingVersion(). + WritingVersion writingVersion_; + // The project identity last observed by poll(), used to detect load/Save-As. // The GUID is the PRIMARY signal (a different stored GUID = a different project // of record = Load, immune to pointer recycling). The pointer disambiguates the diff --git a/src/version_generated.h.in b/src/version_generated.h.in new file mode 100644 index 0000000..b024847 --- /dev/null +++ b/src/version_generated.h.in @@ -0,0 +1,9 @@ +#pragma once +// version_generated.h.in — configure_file TEMPLATE. CMake substitutes @REASAMPLER_VERSION@ +// (the ONE source-of-truth variable in CMakeLists.txt) and writes the result to the build +// tree as version_generated.h. DO NOT edit the generated header — edit REASAMPLER_VERSION +// in CMakeLists.txt; that single edit re-generates this and re-threads the exact string +// (leading zero preserved verbatim) into the binary constant, the ext-state stamp, and the +// show-version action. See app_version.h / .cpp. + +#define REASAMPLER_VERSION_STRING "@REASAMPLER_VERSION@" diff --git a/tests/test_app_version.cpp b/tests/test_app_version.cpp new file mode 100644 index 0000000..97d4c20 --- /dev/null +++ b/tests/test_app_version.cpp @@ -0,0 +1,138 @@ +// Standalone tests for reasampler::app_version — no REAPER, no framework. Covers the +// Phase V (V1) pure version-identity core: the exact-string fidelity of the CMake-sourced +// constant (leading-zero preserved), the semver parse/compare arithmetic a within-channel +// forward migration leans on, and the three-way writing-version classification persist reads +// back from ext state (PreVersioning / Unknown / Stamped). The ext-state I/O (persist) and +// the show-version action (main) are DAW-verified shell. + +#include "../src/app_version.h" + +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- appVersion: exact-string fidelity, leading zero preserved ---------------- + +static void testVersionConstantRendersExactString() { + // The Daniel-fixed string: EXACTLY "0.9.01", two-digit zero-padded patch. This is + // the whole point of sourcing the STRING (not reconstructing from numeric components): + // a normalized "0.9.1" here would be a leading-zero-fidelity regression, and this + // assertion fails against the actual CMake-configured value, not a re-derivation. + CHECK(appVersion() == "0.9.01"); +} + +// --- parseVersion: well-formed, leading zeros, and rejection ------------------ + +static void testParseWellFormed() { + auto v = parseVersion("0.9.01"); + CHECK(v.has_value()); + CHECK(v && v->major == 0 && v->minor == 9 && v->patch == 1); // "01" -> 1 + auto w = parseVersion("1.10.2"); + CHECK(w.has_value()); + CHECK(w && w->major == 1 && w->minor == 10 && w->patch == 2); +} + +static void testParseRejectsMalformed() { + CHECK(!parseVersion("").has_value()); // empty + CHECK(!parseVersion("1.2").has_value()); // too few components + CHECK(!parseVersion("1.2.3.4").has_value()); // too many components + CHECK(!parseVersion("1..2").has_value()); // empty middle component + CHECK(!parseVersion(".1.2").has_value()); // empty leading component + CHECK(!parseVersion("1.2.").has_value()); // empty trailing component + CHECK(!parseVersion("1.2.x").has_value()); // non-digit + CHECK(!parseVersion("-1.2.3").has_value()); // sign + CHECK(!parseVersion("1.2.3-beta").has_value()); // trailing garbage + CHECK(!parseVersion("v1.2.3").has_value()); // prefix + CHECK(!parseVersion(" 1.2.3").has_value()); // leading whitespace +} + +// --- versionLess: NUMERIC ordering (10 > 9, not lexicographic) ---------------- + +static void testOrderingByPatch() { + // 0.9.02 > 0.9.01 (the per-test-build patch increment the spec calls out). + auto a = parseVersion("0.9.01"); + auto b = parseVersion("0.9.02"); + CHECK(a && b); + CHECK(a && b && versionLess(*a, *b)); + CHECK(a && b && !versionLess(*b, *a)); +} + +static void testOrderingIsNumericNotLexicographic() { + // 0.10.01 > 0.9.02 — the case a string compare would get WRONG ("10" < "9" + // lexicographically). This assertion fails if compare regressed to string order. + auto a = parseVersion("0.9.02"); + auto b = parseVersion("0.10.01"); + CHECK(a && b); + CHECK(a && b && versionLess(*a, *b)); + // And a major bump outranks a large minor: 1.0.0 > 0.99.99. + auto c = parseVersion("0.99.99"); + auto d = parseVersion("1.0.0"); + CHECK(c && d && versionLess(*c, *d)); +} + +static void testOrderingIrreflexive() { + // Equal versions are not less-than either way. + auto a = parseVersion("0.9.01"); + auto b = parseVersion("0.9.01"); + CHECK(a && b && !versionLess(*a, *b)); + CHECK(a && b && !versionLess(*b, *a)); +} + +// --- classifyWritingVersion: the three-way stamp classification ---------------- + +static void testAbsentStampIsPreVersioning() { + // An absent key -> getProjExtStateString returns "" -> PreVersioning (a project + // saved before this feature). Silent, not an error, no throw. + WritingVersion wv = classifyWritingVersion(""); + CHECK(wv.kind == WritingVersion::Kind::PreVersioning); + CHECK(wv.raw.empty()); +} + +static void testMalformedStampIsUnknown() { + // A present-but-unparseable stamp -> Unknown (ignored, never a throw). Keeps the raw + // value for diagnostics but does not pretend it is a version. + WritingVersion wv = classifyWritingVersion("garbage-not-a-version"); + CHECK(wv.kind == WritingVersion::Kind::Unknown); + CHECK(wv.raw == "garbage-not-a-version"); +} + +static void testWellFormedStampIsStamped() { + // A well-formed stamp -> Stamped, carrying the EXACT stored string plus the parsed + // triple for comparison. Round-trips the current version through classify. + WritingVersion wv = classifyWritingVersion("0.9.01"); + CHECK(wv.kind == WritingVersion::Kind::Stamped); + CHECK(wv.raw == "0.9.01"); + CHECK(wv.parsed.major == 0 && wv.parsed.minor == 9 && wv.parsed.patch == 1); +} + +static void testStampedStampIsOrderableAgainstCurrent() { + // The migration use case: a stamp read back is comparable to the running build. An + // older stamp (0.9.01) precedes a newer one (0.9.05) numerically. + WritingVersion older = classifyWritingVersion("0.9.01"); + WritingVersion newer = classifyWritingVersion("0.9.05"); + CHECK(older.kind == WritingVersion::Kind::Stamped); + CHECK(newer.kind == WritingVersion::Kind::Stamped); + CHECK(versionLess(older.parsed, newer.parsed)); +} + +int main() { + testVersionConstantRendersExactString(); + testParseWellFormed(); + testParseRejectsMalformed(); + testOrderingByPatch(); + testOrderingIsNumericNotLexicographic(); + testOrderingIrreflexive(); + testAbsentStampIsPreVersioning(); + testMalformedStampIsUnknown(); + testWellFormedStampIsStamped(); + testStampedStampIsOrderableAgainstCurrent(); + + if (g_fail == 0) std::printf("app_version: all tests passed\n"); + else std::printf("app_version: %d CHECK(s) FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +} From 0a9d8200c7b986f9280a819600701701a5574644 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 15:21:47 -0400 Subject: [PATCH 2/4] fix(cmake): remove duplicate app_version.cpp from module sources src/app_version.cpp was listed in the reaper_reasampler target_sources block and also compiled into the app_version static lib that the module links. One compile via the lib is correct; the duplicate TU in the module was an ODR/duplicate-symbol hazard on ld. --- CMakeLists.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a1b5497..991ca37 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -340,7 +340,6 @@ add_library(reaper_reasampler MODULE src/actions.cpp src/bank_book.cpp src/owned_manifest.cpp - src/app_version.cpp ) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest app_version) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) From 5c0e3a8ccf2dc8a8221f3b8e5410fbc6d5003495 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 16:06:24 -0400 Subject: [PATCH 3/4] feat(version): V4 beta-in-isolation channel via -DREASAMPLER_CHANNEL Compile-time channel flag forks a fully isolated reaper_reasampler_beta (namespace, command-id prefix, action names, dock ident, -beta render) from one auditable app_version definition. Stable identity byte-unchanged. --- CMakeLists.txt | 35 +++++- src/actions.cpp | 187 ++++++++++++++++++-------------- src/app_version.cpp | 93 ++++++++++++++-- src/app_version.h | 105 +++++++++++++++++- src/bank_panel.cpp | 25 ++++- src/main.cpp | 190 +++++++++++++++++++++------------ src/persist.cpp | 47 ++++---- src/persist.h | 13 ++- src/render_settings.cpp | 11 +- src/render_settings.h | 20 ++-- src/version_generated.h.in | 24 ++++- tests/test_app_version.cpp | 95 ++++++++++++++++- tests/test_render_settings.cpp | 27 +++-- 13 files changed, 648 insertions(+), 224 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 991ca37..7e3ba1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,8 +18,33 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_POSITION_INDEPENDENT_CODE ON) -# Generate version_generated.h from the one REASAMPLER_VERSION variable. Regenerated at -# configure time whenever the variable changes; the exact string is substituted verbatim. +# --------------------------------------------------------------------------- +# Channel — SINGLE SOURCE OF TRUTH for beta-in-isolation (Phase V, V4). One flag, +# `-DREASAMPLER_CHANNEL=beta`, forks the whole channel identity from one build tree: +# absent (or `stable`) = today's build with BYTE-IDENTICAL identity (binary name, +# ext-state namespace, command-id strings, version render); `beta` = a fully isolated +# `reaper_reasampler_beta` that coexists with stable in one REAPER. The flag reduces to +# ONE bit (REASAMPLER_CHANNEL_IS_BETA) threaded through the SAME configure_file'd header +# as the version string, so the pure app_version module derives every channel-qualified +# identity from it — no scattered #ifdefs. Any value other than exactly `beta` is treated +# as stable (a typo must not silently produce a half-forked build), and we hard-error on +# an unrecognized non-empty value so a misspelled `-DREASAMPLER_CHANNEL=betaa` is caught +# at configure time rather than shipping stable identity under a beta intent. +set(REASAMPLER_CHANNEL "stable" CACHE STRING "Build channel: stable (default) or beta") +if(REASAMPLER_CHANNEL STREQUAL "beta") + set(REASAMPLER_CHANNEL_IS_BETA 1) + set(REASAMPLER_OUTPUT_NAME "reaper_reasampler_beta") +elseif(REASAMPLER_CHANNEL STREQUAL "stable") + set(REASAMPLER_CHANNEL_IS_BETA 0) + set(REASAMPLER_OUTPUT_NAME "reaper_reasampler") +else() + message(FATAL_ERROR + "REASAMPLER_CHANNEL must be 'stable' or 'beta' (got '${REASAMPLER_CHANNEL}')") +endif() + +# Generate version_generated.h from the one REASAMPLER_VERSION variable + the channel bit. +# Regenerated at configure time whenever either changes; the exact string is substituted +# verbatim and the channel bit fans out through app_version. configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/src/version_generated.h.in ${CMAKE_CURRENT_BINARY_DIR}/generated/version_generated.h @@ -343,7 +368,11 @@ add_library(reaper_reasampler MODULE ) target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest app_version) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) -set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") +# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or +# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels' +# artifacts load side-by-side. The CMake TARGET name stays "reaper_reasampler" for both +# configs — one source tree, one target; only the emitted file name forks by channel. +set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "${REASAMPLER_OUTPUT_NAME}") if(WIN32) # Native Win32. REAPER provides nothing extra to link. The bank_panel dialog diff --git a/src/actions.cpp b/src/actions.cpp index c3fa34c..f1d856e 100644 --- a/src/actions.cpp +++ b/src/actions.cpp @@ -21,9 +21,12 @@ #include "actions.h" +#include #include #include +#include "app_version.h" // channelCommandId / channelActionName — one channel-identity point + #include "bank_book.h" // BankBook, nextBankId, TransferResult, kPoolBankId (B1) #include "bank_panel.h" // selection seam + full-height toggles (B3/B4) #include "item_read.h" // shared MediaItem* -> GUID + fixed-lane-name reads (D2 W3-B) @@ -57,22 +60,23 @@ namespace reasampler { namespace { -// FOREVER-STABLE action-id strings. Same family prefix as main.cpp's capture/panel -// actions; each full string is minted into a persistent command id and user -// keybindings key off it — NEVER change these after ship. -constexpr const char* kIdToggleMode = "CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE"; -constexpr const char* kIdActivateArrange = "CEREBELLUM_REASAMPLER_VIEW_ACTIVATE_ARRANGE"; -constexpr const char* kIdActivateDesign = "CEREBELLUM_REASAMPLER_VIEW_ACTIVATE_DESIGN"; -constexpr const char* kIdTagDesign = "CEREBELLUM_REASAMPLER_VIEW_TAG_DESIGN"; -constexpr const char* kIdTagArrange = "CEREBELLUM_REASAMPLER_VIEW_TAG_ARRANGE"; -constexpr const char* kIdUntag = "CEREBELLUM_REASAMPLER_VIEW_UNTAG"; -constexpr const char* kIdShowBoth = "CEREBELLUM_REASAMPLER_VIEW_SHOW_BOTH"; +// FOREVER-STABLE action-id SUFFIXES (Phase V, V4). The channel family prefix is prepended +// at register time via channelCommandId (app_version), so stable rebuilds the exact shipped +// id ("CEREBELLUM_REASAMPLER_VIEW_TOGGLE_MODE") and beta yields the isolated forever-family +// id ("CEREBELLUM_REASAMPLER_BETA_VIEW_TOGGLE_MODE"). Each composed id is minted into a +// persistent command id user keybindings key off — NEVER change a shipped suffix after ship. +constexpr const char* kIdToggleMode = "VIEW_TOGGLE_MODE"; +constexpr const char* kIdActivateArrange = "VIEW_ACTIVATE_ARRANGE"; +constexpr const char* kIdActivateDesign = "VIEW_ACTIVATE_DESIGN"; +constexpr const char* kIdTagDesign = "VIEW_TAG_DESIGN"; +constexpr const char* kIdTagArrange = "VIEW_TAG_ARRANGE"; +constexpr const char* kIdUntag = "VIEW_UNTAG"; +constexpr const char* kIdShowBoth = "VIEW_SHOW_BOTH"; // D2 Wave 3-B item-level mode moves — the item analog of the track tag family. Same -// FOREVER-STABLE contract: minted into a persistent command id, user keybindings key off -// each — NEVER change these strings after ship. -constexpr const char* kIdMoveItemsDesign = "CEREBELLUM_REASAMPLER_VIEW_MOVE_ITEMS_DESIGN"; -constexpr const char* kIdMoveItemsArrange = "CEREBELLUM_REASAMPLER_VIEW_MOVE_ITEMS_ARRANGE"; -constexpr const char* kIdUntagItems = "CEREBELLUM_REASAMPLER_VIEW_UNTAG_ITEMS"; +// FOREVER-STABLE contract (suffix composed with the channel prefix) — NEVER change these. +constexpr const char* kIdMoveItemsDesign = "VIEW_MOVE_ITEMS_DESIGN"; +constexpr const char* kIdMoveItemsArrange = "VIEW_MOVE_ITEMS_ARRANGE"; +constexpr const char* kIdUntagItems = "VIEW_UNTAG_ITEMS"; // The live session the actions mutate. Set once by designViewRegisterActions and // read by the hookcommand handler. Not owned here (main.cpp owns g_session). @@ -103,15 +107,35 @@ gaccel_register_t g_accelMoveItemsDesign{}; gaccel_register_t g_accelMoveItemsArrange{}; gaccel_register_t g_accelUntagItems{}; -// Mints a command id from a stable string and registers its gaccel (Actions-list -// entry with `desc`). Returns the command id (0 on failure). The gaccel storage is -// caller-owned and must outlive the module (the file-scope g_accel* above). -int registerAction(reaper_plugin_info_t* rec, const char* stableId, - gaccel_register_t& accel, const char* desc) { - const int cmd = rec->Register("command_id", (void*)stableId); +// Durable store of composed, channel-qualified strings (ids + labels). A std::deque never +// invalidates references on push_back, so a c_str() handed to REAPER (a command_id at +// register, a gaccel desc for its lifetime) stays valid until process exit. Memoized by +// suffix so register and the mirror-unregister get the SAME id pointer for a given action. +std::deque g_strStore; + +// Returns the channel-qualified command id for `suffix`, interning it once. Called by BOTH +// registerAction and the unregister path, so a '-command_id' presents the identical string. +const char* channelIdFor(const char* suffix) { + const std::string composed = channelCommandId(suffix); + for (const std::string& s : g_strStore) + if (s == composed) return s.c_str(); + g_strStore.push_back(composed); + return g_strStore.back().c_str(); +} + +// Mints a command id from a channel-qualified SUFFIX and registers its gaccel (Actions-list +// entry with a channel-qualified label PHRASE). Returns the command id (0 on failure). Both +// the composed id and label are interned durably (g_strStore) — REAPER holds the desc +// pointer, and the id must survive to the mirror-unregister. The gaccel storage itself is +// caller-owned (the file-scope g_accel* above). +int registerAction(reaper_plugin_info_t* rec, const char* suffix, + gaccel_register_t& accel, const char* phrase) { + const char* id = channelIdFor(suffix); + const int cmd = rec->Register("command_id", (void*)id); if (cmd) { + g_strStore.push_back(channelActionName(phrase)); accel.accel.cmd = cmd; - accel.desc = desc; + accel.desc = g_strStore.back().c_str(); rec->Register("gaccel", (void*)&accel); } return cmd; @@ -319,27 +343,27 @@ void designViewRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* ses // command_id -> gaccel for each. The single hookcommand that routes these lives // in main.cpp (one hook per extension); designViewHandleCommand services them. g_cmdToggleMode = registerAction(rec, kIdToggleMode, g_accelToggleMode, - "ReaSampler: toggle Design View mode"); + "toggle Design View mode"); g_cmdActivateArrange = registerAction(rec, kIdActivateArrange, g_accelActivateArrange, - "ReaSampler: activate mode Arrange"); + "activate mode Arrange"); g_cmdActivateDesign = registerAction(rec, kIdActivateDesign, g_accelActivateDesign, - "ReaSampler: activate mode Design"); + "activate mode Design"); g_cmdTagDesign = registerAction(rec, kIdTagDesign, g_accelTagDesign, - "ReaSampler: tag selected tracks -> Design"); + "tag selected tracks -> Design"); g_cmdTagArrange = registerAction(rec, kIdTagArrange, g_accelTagArrange, - "ReaSampler: tag selected tracks -> Arrange"); + "tag selected tracks -> Arrange"); g_cmdUntag = registerAction(rec, kIdUntag, g_accelUntag, - "ReaSampler: untag selected tracks"); + "untag selected tracks"); g_cmdShowBoth = registerAction(rec, kIdShowBoth, g_accelShowBoth, - "ReaSampler: show both for selected tracks"); + "show both for selected tracks"); // Item-level mode moves (D2 W3-B): the item analog of the track tag family. g_cmdMoveItemsDesign = registerAction(rec, kIdMoveItemsDesign, g_accelMoveItemsDesign, - "ReaSampler: move selected items -> Design"); + "move selected items -> Design"); g_cmdMoveItemsArrange = registerAction(rec, kIdMoveItemsArrange, g_accelMoveItemsArrange, - "ReaSampler: move selected items -> Arrange"); + "move selected items -> Arrange"); g_cmdUntagItems = registerAction(rec, kIdUntagItems, g_accelUntagItems, - "ReaSampler: untag selected items"); + "untag selected items"); } bool designViewHandleCommand(int command) { @@ -367,26 +391,28 @@ void designViewUnregisterActions(reaper_plugin_info_t* rec) { // Mirror-unregister with '-'-prefixed strings, per the contract's unload rule. // gaccel first, then the command_id string (reverse of registration order — the item // moves registered last, so they tear down first). + // Each '-command_id' re-presents the SAME interned, channel-qualified id (channelIdFor + // returns the memoized pointer registered above), so the unregister matches exactly. rec->Register("-gaccel", (void*)&g_accelUntagItems); - rec->Register("-command_id", (void*)kIdUntagItems); + rec->Register("-command_id", (void*)channelIdFor(kIdUntagItems)); rec->Register("-gaccel", (void*)&g_accelMoveItemsArrange); - rec->Register("-command_id", (void*)kIdMoveItemsArrange); + rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsArrange)); rec->Register("-gaccel", (void*)&g_accelMoveItemsDesign); - rec->Register("-command_id", (void*)kIdMoveItemsDesign); + rec->Register("-command_id", (void*)channelIdFor(kIdMoveItemsDesign)); rec->Register("-gaccel", (void*)&g_accelShowBoth); - rec->Register("-command_id", (void*)kIdShowBoth); + rec->Register("-command_id", (void*)channelIdFor(kIdShowBoth)); rec->Register("-gaccel", (void*)&g_accelUntag); - rec->Register("-command_id", (void*)kIdUntag); + rec->Register("-command_id", (void*)channelIdFor(kIdUntag)); rec->Register("-gaccel", (void*)&g_accelTagArrange); - rec->Register("-command_id", (void*)kIdTagArrange); + rec->Register("-command_id", (void*)channelIdFor(kIdTagArrange)); rec->Register("-gaccel", (void*)&g_accelTagDesign); - rec->Register("-command_id", (void*)kIdTagDesign); + rec->Register("-command_id", (void*)channelIdFor(kIdTagDesign)); rec->Register("-gaccel", (void*)&g_accelActivateDesign); - rec->Register("-command_id", (void*)kIdActivateDesign); + rec->Register("-command_id", (void*)channelIdFor(kIdActivateDesign)); rec->Register("-gaccel", (void*)&g_accelActivateArrange); - rec->Register("-command_id", (void*)kIdActivateArrange); + rec->Register("-command_id", (void*)channelIdFor(kIdActivateArrange)); rec->Register("-gaccel", (void*)&g_accelToggleMode); - rec->Register("-command_id", (void*)kIdToggleMode); + rec->Register("-command_id", (void*)channelIdFor(kIdToggleMode)); g_session = nullptr; } @@ -409,20 +435,22 @@ void designViewUnregisterActions(reaper_plugin_info_t* rec) { namespace { -// FOREVER-STABLE multi-bank action-id strings. Same CEREBELLUM_REASAMPLER_ family -// prefix; each is minted into a persistent command id user keybindings key off — -// NEVER change these after ship. -constexpr const char* kIdBankCreate = "CEREBELLUM_REASAMPLER_BANK_CREATE"; -constexpr const char* kIdBankRename = "CEREBELLUM_REASAMPLER_BANK_RENAME"; -constexpr const char* kIdBankDelete = "CEREBELLUM_REASAMPLER_BANK_DELETE"; -constexpr const char* kIdBankEvacuate = "CEREBELLUM_REASAMPLER_BANK_EVACUATE"; -constexpr const char* kIdBankActivateNext = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE_NEXT"; -constexpr const char* kIdBankActivatePool = "CEREBELLUM_REASAMPLER_BANK_ACTIVATE_POOL"; -constexpr const char* kIdBankMoveSel = "CEREBELLUM_REASAMPLER_BANK_MOVE_SELECTED"; -constexpr const char* kIdBankCopySel = "CEREBELLUM_REASAMPLER_BANK_COPY_SELECTED"; -constexpr const char* kIdBankRemoveSel = "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED"; -constexpr const char* kIdBankPoolFull = "CEREBELLUM_REASAMPLER_BANK_POOL_FULLHEIGHT"; -constexpr const char* kIdBankBanksFull = "CEREBELLUM_REASAMPLER_BANK_BANKS_FULLHEIGHT"; +// FOREVER-STABLE multi-bank action-id SUFFIXES (Phase V, V4). The channel family prefix is +// prepended at register via channelCommandId (as with the Design View family above) — +// stable rebuilds the shipped id, beta the isolated one. NEVER change a shipped suffix. +// Each suffix + the stable prefix must byte-match the pre-V4 shipped literal exactly +// (e.g. "BANK_REMOVE_SELECTED" -> "CEREBELLUM_REASAMPLER_BANK_REMOVE_SELECTED"). +constexpr const char* kIdBankCreate = "BANK_CREATE"; +constexpr const char* kIdBankRename = "BANK_RENAME"; +constexpr const char* kIdBankDelete = "BANK_DELETE"; +constexpr const char* kIdBankEvacuate = "BANK_EVACUATE"; +constexpr const char* kIdBankActivateNext = "BANK_ACTIVATE_NEXT"; +constexpr const char* kIdBankActivatePool = "BANK_ACTIVATE_POOL"; +constexpr const char* kIdBankMoveSel = "BANK_MOVE_SELECTED"; +constexpr const char* kIdBankCopySel = "BANK_COPY_SELECTED"; +constexpr const char* kIdBankRemoveSel = "BANK_REMOVE_SELECTED"; +constexpr const char* kIdBankPoolFull = "BANK_POOL_FULLHEIGHT"; +constexpr const char* kIdBankBanksFull = "BANK_BANKS_FULLHEIGHT"; int g_cmdBankCreate = 0; int g_cmdBankRename = 0; @@ -831,27 +859,27 @@ void bankRegisterActions(reaper_plugin_info_t* rec, ReaSamplerSession* session) g_session = session; // shared with the Design View family; same live session g_cmdBankCreate = registerAction(rec, kIdBankCreate, g_accelBankCreate, - "ReaSampler: create bank"); + "create bank"); g_cmdBankRename = registerAction(rec, kIdBankRename, g_accelBankRename, - "ReaSampler: rename bank"); + "rename bank"); g_cmdBankDelete = registerAction(rec, kIdBankDelete, g_accelBankDelete, - "ReaSampler: delete bank"); + "delete bank"); g_cmdBankEvacuate = registerAction(rec, kIdBankEvacuate, g_accelBankEvacuate, - "ReaSampler: evacuate bank to pool"); + "evacuate bank to pool"); g_cmdBankActivateNext = registerAction(rec, kIdBankActivateNext, g_accelBankActivateNext, - "ReaSampler: activate next bank (cycle)"); + "activate next bank (cycle)"); g_cmdBankActivatePool = registerAction(rec, kIdBankActivatePool, g_accelBankActivatePool, - "ReaSampler: activate pool"); + "activate pool"); g_cmdBankMoveSel = registerAction(rec, kIdBankMoveSel, g_accelBankMoveSel, - "ReaSampler: move selected samples to bank"); + "move selected samples to bank"); g_cmdBankCopySel = registerAction(rec, kIdBankCopySel, g_accelBankCopySel, - "ReaSampler: copy selected samples to bank"); + "copy selected samples to bank"); g_cmdBankRemoveSel = registerAction(rec, kIdBankRemoveSel, g_accelBankRemoveSel, - "ReaSampler: remove selected samples"); + "remove selected samples"); g_cmdBankPoolFull = registerAction(rec, kIdBankPoolFull, g_accelBankPoolFull, - "ReaSampler: toggle pool full-height"); + "toggle pool full-height"); g_cmdBankBanksFull = registerAction(rec, kIdBankBanksFull, g_accelBankBanksFull, - "ReaSampler: toggle banks full-height"); + "toggle banks full-height"); } bool bankHandleCommand(int command) { @@ -873,29 +901,30 @@ bool bankHandleCommand(int command) { } void bankUnregisterActions(reaper_plugin_info_t* rec) { - // Mirror-unregister with '-'-prefixed strings, reverse of registration order. + // Mirror-unregister with '-'-prefixed strings, reverse of registration order. Each + // '-command_id' re-presents the same interned channel-qualified id (channelIdFor). rec->Register("-gaccel", (void*)&g_accelBankBanksFull); - rec->Register("-command_id", (void*)kIdBankBanksFull); + rec->Register("-command_id", (void*)channelIdFor(kIdBankBanksFull)); rec->Register("-gaccel", (void*)&g_accelBankPoolFull); - rec->Register("-command_id", (void*)kIdBankPoolFull); + rec->Register("-command_id", (void*)channelIdFor(kIdBankPoolFull)); rec->Register("-gaccel", (void*)&g_accelBankRemoveSel); - rec->Register("-command_id", (void*)kIdBankRemoveSel); + rec->Register("-command_id", (void*)channelIdFor(kIdBankRemoveSel)); rec->Register("-gaccel", (void*)&g_accelBankCopySel); - rec->Register("-command_id", (void*)kIdBankCopySel); + rec->Register("-command_id", (void*)channelIdFor(kIdBankCopySel)); rec->Register("-gaccel", (void*)&g_accelBankMoveSel); - rec->Register("-command_id", (void*)kIdBankMoveSel); + rec->Register("-command_id", (void*)channelIdFor(kIdBankMoveSel)); rec->Register("-gaccel", (void*)&g_accelBankActivatePool); - rec->Register("-command_id", (void*)kIdBankActivatePool); + rec->Register("-command_id", (void*)channelIdFor(kIdBankActivatePool)); rec->Register("-gaccel", (void*)&g_accelBankActivateNext); - rec->Register("-command_id", (void*)kIdBankActivateNext); + rec->Register("-command_id", (void*)channelIdFor(kIdBankActivateNext)); rec->Register("-gaccel", (void*)&g_accelBankEvacuate); - rec->Register("-command_id", (void*)kIdBankEvacuate); + rec->Register("-command_id", (void*)channelIdFor(kIdBankEvacuate)); rec->Register("-gaccel", (void*)&g_accelBankDelete); - rec->Register("-command_id", (void*)kIdBankDelete); + rec->Register("-command_id", (void*)channelIdFor(kIdBankDelete)); rec->Register("-gaccel", (void*)&g_accelBankRename); - rec->Register("-command_id", (void*)kIdBankRename); + rec->Register("-command_id", (void*)channelIdFor(kIdBankRename)); rec->Register("-gaccel", (void*)&g_accelBankCreate); - rec->Register("-command_id", (void*)kIdBankCreate); + rec->Register("-command_id", (void*)channelIdFor(kIdBankCreate)); // g_session is shared with the Design View family; designViewUnregisterActions // also nulls it. Nulling twice is harmless. Leave it to whichever runs last. diff --git a/src/app_version.cpp b/src/app_version.cpp index 9a995c3..b51e7cd 100644 --- a/src/app_version.cpp +++ b/src/app_version.cpp @@ -1,24 +1,99 @@ -// app_version.cpp — implementation of the pure version-identity core (Phase V, V1). -// See app_version.h for the contract. The version STRING itself comes from -// version_generated.h (produced by CMake configure_file from the one REASAMPLER_VERSION -// variable) — this TU just re-exports it and owns the pure parse/compare/classify logic. +// app_version.cpp — implementation of the pure version-identity core (Phase V, V1 + V4). +// See app_version.h for the contract. The version STRING and the channel bit both come +// from version_generated.h (produced by CMake configure_file from the one +// REASAMPLER_VERSION variable + the REASAMPLER_CHANNEL flag) — this TU re-exports them and +// owns every pure derivation: the channel-qualified identity strings (V4) and the +// parse/compare/classify logic (V1). No #ifdef forks leak beyond this file; the shells +// consume the accessors below, so channel identity is one auditable definition. #include "app_version.h" #include -#include "version_generated.h" // REASAMPLER_VERSION_STRING — configure_file'd from CMake +#include "version_generated.h" // REASAMPLER_VERSION_STRING + REASAMPLER_CHANNEL_IS_BETA namespace reasampler { +namespace { +// The one channel predicate every derivation below branches on — the single point the +// configure_file'd bit enters the pure module. constexpr so the branches fold at compile +// time; the accessors still return by const ref for a stable shared instance. +constexpr bool kIsBeta = (REASAMPLER_CHANNEL_IS_BETA != 0); +} // namespace + +Channel channel() { return kIsBeta ? Channel::Beta : Channel::Stable; } + +bool isBeta() { return kIsBeta; } + const std::string& appVersion() { - // Function-local static: initialized once from the compile-time string, returned by - // const ref so callers share the one authoritative instance. The macro is the exact - // CMake value, leading zero and all. - static const std::string kVersion = REASAMPLER_VERSION_STRING; + // The user-visible render. Stable: EXACTLY the CMake string (leading zero and all). + // Beta: the same numeric string plus a plain "-beta" suffix (V2). Function-local + // static so callers share one authoritative instance. + static const std::string kVersion = + kIsBeta ? std::string(REASAMPLER_VERSION_STRING) + "-beta" + : std::string(REASAMPLER_VERSION_STRING); return kVersion; } +const std::string& stampVersion() { + // The ext-state stamp value — the NUMERIC TRIPLE ONLY, IDENTICAL on both channels. + // No "-beta" suffix: it must parse as Stamped on read-back (a suffixed stamp classifies + // as Unknown), and stable's stamp stays byte-identical regardless of the channel build. + // The channel is carried by extStateNamespace(), never baked into the stamp. + static const std::string kStamp = REASAMPLER_VERSION_STRING; + return kStamp; +} + +const std::string& extStateNamespace() { + // Stable "reasampler" is byte-identical to the pre-V4 build; beta is isolated. + // FOREVER-STABLE per channel. + static const std::string kNs = kIsBeta ? "reasampler_beta" : "reasampler"; + return kNs; +} + +const std::string& commandIdPrefix() { + // Stable prefix is byte-identical to every shipped command id; beta is a distinct + // forever-family. FOREVER-STABLE per channel. + static const std::string kPrefix = + kIsBeta ? "CEREBELLUM_REASAMPLER_BETA_" : "CEREBELLUM_REASAMPLER_"; + return kPrefix; +} + +const std::string& actionDisplayPrefix() { + // Actions-list legibility: two channels must be distinguishable by name. Trailing + // space so callers append the action phrase directly. + static const std::string kDisp = kIsBeta ? "ReaSampler beta: " : "ReaSampler: "; + return kDisp; +} + +const std::string& binaryName() { + static const std::string kName = + kIsBeta ? "reaper_reasampler_beta" : "reaper_reasampler"; + return kName; +} + +const std::string& dockTitle() { + static const std::string kTitle = + kIsBeta ? "ReaSampler Bank beta" : "ReaSampler Bank"; + return kTitle; +} + +const std::string& dockIdent() { + // Persisted dock-position ident — FOREVER-STABLE per channel (changing it strands the + // saved dock slot). Beta qualified so the two panels do not fight over one slot. + static const std::string kIdent = + kIsBeta ? "reasampler_bank_panel_beta" : "reasampler_bank_panel"; + return kIdent; +} + +std::string channelCommandId(const std::string& suffix) { + return commandIdPrefix() + suffix; +} + +std::string channelActionName(const std::string& phrase) { + return actionDisplayPrefix() + phrase; +} + std::optional parseVersion(const std::string& s) { // Split on '.' into exactly three non-empty all-digit components. No sign, no // whitespace, no trailing garbage. Leading zeros are allowed (0.9.01 parses to diff --git a/src/app_version.h b/src/app_version.h index 98ee101..052c273 100644 --- a/src/app_version.h +++ b/src/app_version.h @@ -1,5 +1,5 @@ #pragma once -// app_version — the REAPER-free version-identity core (Phase V, V1). The single +// app_version — the REAPER-free version-identity core (Phase V, V1 + V4). The single // source of truth for the version STRING lives in CMake (a `REASAMPLER_VERSION` // variable threaded in via configure_file -> version_generated.h); this module // re-exports it as the canonical constant and owns every pure operation on it: the @@ -7,6 +7,17 @@ // migration will lean on, and the "which version wrote this project" result that // persist reads back from ext state (absent stamp = pre-versioning, never an error). // +// V4 (beta-in-isolation) extends this module into the SINGLE SOURCE OF TRUTH FOR +// CHANNEL IDENTITY too. A compile-time flag (`-DREASAMPLER_CHANNEL=beta`, threaded +// through the same configure_file'd version_generated.h as REASAMPLER_CHANNEL_IS_BETA) +// selects stable (the default, absent-flag build — byte-for-byte today's identity) or +// a fully isolated beta build. Every channel-qualified identity string the shells +// register with REAPER — the display suffix, the ext-state namespace, the command-id +// prefix, the Actions-list name prefix, the binary/dock idents — is DERIVED HERE from +// the one channel bit, so no scattered #ifdef forks live across the translation units; +// the shells just consume these accessors. This keeps "what makes a beta a beta" one +// auditable definition and makes the channel-derived rendering unit-testable. +// // PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library // only. Builds and unit-tests without REAPER (mirror of bank_model / tail_control). // @@ -21,12 +32,96 @@ namespace reasampler { -// The canonical version string — EXACTLY the value of the CMake `REASAMPLER_VERSION` -// variable (see version_generated.h, produced by configure_file). One edit point: -// changing that variable changes this constant, the ext-state stamp, and the -// show-version action output with no other edits. Leading zero preserved verbatim. +// --- Channel identity (V4, beta-in-isolation) --------------------------------------- +// +// The build channel, fixed at compile time by REASAMPLER_CHANNEL_IS_BETA (0 = stable, +// the default absent-flag build; 1 = beta, from -DREASAMPLER_CHANNEL=beta). Stable is +// today's build with byte-identical identity in EVERY string below — any divergence on +// the stable channel is a defect. Beta forks every identity so a beta binary coexists +// with stable in one REAPER (both are dlopen'd at startup) without colliding on project +// ext-state, keybindings, or any REAPER-global registration. +enum class Channel { Stable, Beta }; + +// The channel this build was compiled for. Constant per binary. +Channel channel(); + +// True on the beta build only. Convenience over channel() == Channel::Beta. +bool isBeta(); + +// The user-visible version render. Stable: EXACTLY the CMake string ("0.9.01"). Beta: +// that string plus a plain "-beta" suffix ("0.9.01-beta") — a plain suffix, NOT a +// git-describe decoration (V2, Daniel-fixed). This is what the show-version action and +// the bank-panel readout display. It is NOT the ext-state stamp value (see stampVersion). const std::string& appVersion(); +// The ext-state STAMP value — the writing-version recorded into a saved project. This is +// the NUMERIC TRIPLE ONLY ("0.9.01") on BOTH channels: it deliberately carries NO channel +// suffix, so (a) parseVersion classifies it as Stamped when its own channel reads it back +// (a "-beta"-suffixed stamp would classify as Unknown — the V4 stamp-classifiability +// requirement), and (b) stable's stamp value is byte-identical regardless of the channel +// build. The channel is carried by the ISOLATED namespace (see extStateNamespace), never +// baked into the stamp. Distinct from appVersion() precisely so the display can say +// "-beta" while the stamp stays classifiable and stable-identical. +const std::string& stampVersion(); + +// The project ext-state namespace this channel reads and writes. Stable: "reasampler" +// (byte-identical to the pre-V4 build). Beta: "reasampler_beta". FOREVER-STABLE per +// channel once shipped — changing either orphans every already-saved project's state. +// +// ISOLATION SEMANTICS (V4, accepted — not a bug): a channel reads/writes ONLY its own +// namespace. A project saved by stable shows empty/default ReaSampler state when opened +// in beta, and vice versa. There is NO cross-namespace read, migration, or fallback in +// this wave — that isolation is the safety property (a beta can never read or rewrite a +// stable project's bank/view/tail state). +const std::string& extStateNamespace(); + +// The FOREVER-STABLE command-id prefix every bindable action mints its id from. Stable: +// "CEREBELLUM_REASAMPLER_" (byte-identical to the shipped ids). Beta: +// "CEREBELLUM_REASAMPLER_BETA_", a DISTINCT forever-family so beta and stable actions +// never collide in REAPER's one Actions list and their keybindings stay independent. +// Callers concatenate their per-action suffix onto this (e.g. prefix + "CAPTURE_TRACK"). +// PERMANENT once a beta ships — mark any minted id FOREVER-STABLE like stable's. +const std::string& commandIdPrefix(); + +// The Actions-list DISPLAY-NAME prefix, so two coexisting channels are distinguishable in +// REAPER's Actions list. Stable: "ReaSampler: " (unchanged). Beta: "ReaSampler beta: ". +// Callers build a gaccel desc as actionDisplayPrefix() + "capture selected track", etc. +const std::string& actionDisplayPrefix(); + +// The binary/module OUTPUT NAME base. Stable: "reaper_reasampler". Beta: +// "reaper_reasampler_beta". Mirrors the CMake OUTPUT_NAME (which is the authoritative +// artifact name); exposed here for any in-binary self-identification. REAPER dlopen's +// any reaper_* module, so both channels load side-by-side. +const std::string& binaryName(); + +// The docked bank-panel identity strings, channel-qualified so the two panels are +// distinguishable and do not fight over one persisted dock slot (a REAPER-global +// collision surface — DockWindowAddEx's identstr keys the saved dock position). +// dockTitle() — the visible dock tab title. Stable: "ReaSampler Bank". +// Beta: "ReaSampler Bank beta". +// dockIdent() — the persisted dock-position ident. Stable: "reasampler_bank_panel". +// Beta: "reasampler_bank_panel_beta". FOREVER-STABLE per channel. +const std::string& dockTitle(); +const std::string& dockIdent(); + +// --- Channel-qualified action id / name builders ------------------------------------ +// +// The two composition helpers every action-registering shell (main.cpp, actions.cpp) +// funnels through, so command ids and Actions-list names are qualified IDENTICALLY on +// every channel from ONE definition — no shell re-implements the concatenation. +// +// channelCommandId(suffix): commandIdPrefix() + suffix. `suffix` is the per-action tail +// WITHOUT the family prefix (e.g. "CAPTURE_TRACK", "SHOW_VERSION"). Stable yields the +// exact shipped id ("CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); beta yields the isolated +// forever-family id ("CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK"). FOREVER-STABLE per +// channel — a suffix, once shipped, is as permanent as the prefix. +// channelActionName(phrase): actionDisplayPrefix() + phrase. `phrase` is the action's +// human description WITHOUT the "ReaSampler: " lead (e.g. "capture selected track(s)"). +// Stable yields "ReaSampler: capture selected track(s)"; beta prefixes "ReaSampler beta: " +// so the two channels' actions are distinguishable in one Actions list. +std::string channelCommandId(const std::string& suffix); +std::string channelActionName(const std::string& phrase); + // A parsed semver triple. Kept minimal — major.minor.patch as integers, for ORDERING // only. It deliberately does NOT round-trip back to the display string (the leading // zero is a rendering concern owned by the authoritative string, not reconstructable diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index 69864c3..40acab0 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -148,6 +148,9 @@ constexpr int kFooterHeight = 26; const LICE_pixel kColFooterBg = LICE_RGBA(20, 20, 22, 255); const LICE_pixel kColFooterBorder = LICE_RGBA(70, 70, 76, 255); const COLORREF kRgbFooterText = RGB(190, 205, 198); +// Dimmer than the tail label — the version/channel readout is passive identification, +// not an interactive control, so it recedes visually (V3 unobtrusive placement). +const COLORREF kRgbFooterVersion = RGB(120, 128, 124); // --- Vertical split + region headers + tab strip (Phase B4) ------------------- // @@ -528,13 +531,27 @@ void drawTailFooter(LICE_IBitmap* bmp, int w, int h) { HDC dc = bmp->getDC(); if (!dc) return; + SetBkMode(dc, TRANSPARENT); + + // Tail-mode toggle, left-aligned (the interactive control — footer clicks cycle it). const std::string label = tailToggleLabel(currentTail()); RECT rc = f; rc.left += 8; SetTextColor(dc, kRgbFooterText); - SetBkMode(dc, TRANSPARENT); DrawText(dc, label.c_str(), -1, &rc, DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); + + // Version/channel readout (Phase V, V3/V4), right-aligned in the same footer strip so + // it is always visible but unobtrusive. appVersion() renders "0.9.01" on stable and + // "0.9.01-beta" on beta, so a beta panel self-identifies its channel here. Right inset + // matches the left inset; DT_RIGHT keeps it clear of the left-aligned tail label + // (the two never overlap at normal panel widths — the label is short, the readout is + // ~10 chars, and DT_END_ELLIPSIS on both degrades gracefully if a panel is ever tiny). + RECT vrc = f; + vrc.right -= 8; + SetTextColor(dc, kRgbFooterVersion); + DrawText(dc, reasampler::appVersion().c_str(), -1, &vrc, + DT_RIGHT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); } // True iff client-relative (x, y) falls inside the (non-degenerate) footer strip. @@ -1993,7 +2010,11 @@ void openPanel() { GetMainHwnd(), dlgProc, 0); if (!g_panel.hwnd) return; - DockWindowAddEx(g_panel.hwnd, "ReaSampler Bank", "reasampler_bank_panel", true); + // Channel-qualified dock identity (Phase V, V4). The title and the persisted-position + // identstr both come from app_version, so a beta panel is distinguishable ("ReaSampler + // Bank beta") and does not fight over stable's saved dock slot (the identstr is a + // REAPER-global collision surface — it keys the persisted dock position). + DockWindowAddEx(g_panel.hwnd, dockTitle().c_str(), dockIdent().c_str(), true); DockWindowActivate(g_panel.hwnd); g_panel.open = true; diff --git a/src/main.cpp b/src/main.cpp index 691b01f..d0041d8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -18,6 +18,7 @@ #include "reaper_plugin.h" #include "reaper_plugin_functions.h" +#include #include #include @@ -34,12 +35,28 @@ #include "track_guid.h" #include "view.h" -// Persistent action-id prefix for the ReaSampler action family. -// Every bindable action (capture / insert / slot / verify) mints its command id -// from a string beginning with this prefix, e.g. "CEREBELLUM_REASAMPLER_CAPTURE_TRACK". -// FOREVER-STABLE once shipped: user keybindings key off these strings, so the -// prefix and any minted id must never change after release. -#define REASAMPLER_ACTION_PREFIX "CEREBELLUM_REASAMPLER_" +// Persistent action-id family (Phase V, V4 — channel-qualified). Every bindable action +// mints its command id from commandIdPrefix() + a per-action SUFFIX, and its Actions-list +// name from actionDisplayPrefix() + a phrase, both derived from the ONE channel bit in the +// pure app_version module (channelCommandId / channelActionName). Stable rebuilds the exact +// shipped id ("CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); beta yields the isolated forever- +// family id ("CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK"). FOREVER-STABLE per channel: a +// shipped suffix is as permanent as the prefix; user keybindings key off the composed id. +// +// The composed id strings are held here for the module's lifetime (idStore) so both the +// register call and the mirroring '-command_id' unregister pass the SAME stable pointer. +// A std::deque (NOT vector) is used deliberately: it never invalidates references to +// existing elements on push_back, so a c_str() handed out early stays valid after later +// interning — the unload path re-presents these same pointers. +static std::deque g_idStore; + +// Interns a composed command-id string for the module lifetime and returns its C string. +// Appended-to only during startup registration and read on unload; never cleared until +// process exit, and deque guarantees the returned pointer stays valid. +static const char* internCmdId(const std::string& suffix) { + g_idStore.push_back(reasampler::channelCommandId(suffix)); + return g_idStore.back().c_str(); +} // Globals other files reference via `extern`. REAPER_PLUGIN_HINSTANCE g_hInst = nullptr; // this module's instance handle @@ -63,9 +80,16 @@ reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct // scope and sized to the table. FOREVER-STABLE id strings live in the table. static std::vector g_captureCmdIds; static std::vector g_captureAccels; +// Channel-qualified capture-action labels, one per table row. REAPER holds each gaccel's +// `desc` pointer, so the composed strings live here for the module lifetime (parallel to +// g_captureAccels; never resized after the registration loop sets it). +static std::vector g_captureDescs; -// Retired capture-action command-id strings. Kept ONLY to mirror-unregister them on -// unload so a user's stale keybindings are cleaned up. Never re-register these. +// Retired capture-action command-id SUFFIXES. Kept ONLY to mirror-unregister them on +// unload so a user's stale keybindings are cleaned up. Never re-register these. Composed +// through the channel prefix at unload (channelCommandId) so a beta unload clears beta- +// qualified retired ids and a stable unload clears stable's — each channel cleans up only +// its own family. // * The M7 four-mode ids (tracks/items/razor WET). // * CAPTURE_MASTER and CAPTURE_MASTER_REALTIME — the master offline scope and the // master realtime action are REMOVED (capture is now item + track only; realtime @@ -73,14 +97,14 @@ static std::vector g_captureAccels; // * CAPTURE_ITEM_TAIL and CAPTURE_TRACK_TAIL — the former per-action tail variants // are REMOVED; tail is now a panel-setting toggle, not a paired action. Retired so // old keybindings clear. -static const char* const kRetiredCaptureCmdStrings[] = { - "CEREBELLUM_REASAMPLER_CAPTURE_TRACKS_WET", - "CEREBELLUM_REASAMPLER_CAPTURE_ITEMS_WET", - "CEREBELLUM_REASAMPLER_CAPTURE_RAZOR_WET", - "CEREBELLUM_REASAMPLER_CAPTURE_MASTER", - "CEREBELLUM_REASAMPLER_CAPTURE_MASTER_REALTIME", - "CEREBELLUM_REASAMPLER_CAPTURE_ITEM_TAIL", - "CEREBELLUM_REASAMPLER_CAPTURE_TRACK_TAIL", +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", }; // Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string. @@ -811,6 +835,25 @@ static gaccel_register_t g_accelCaptureTrackRealtime{}; static gaccel_register_t g_accelCancelRealtime{}; static gaccel_register_t g_accelShowVersion{}; +// gaccel desc storage. The Actions-list label is channel-qualified at runtime +// (channelActionName) so it cannot be a string literal; REAPER holds the gaccel's `desc` +// pointer, so each label lives here for the module lifetime. Composed once at registration. +static std::string g_descToggleBankPanel; +static std::string g_descInsertSelected; +static std::string g_descInsertSelectedConform; +static std::string g_descCaptureTrackRealtime; +static std::string g_descCancelRealtime; +static std::string g_descShowVersion; + +// Composed command-id strings (channel-qualified), interned so register and the mirroring +// '-command_id' unregister pass the SAME pointer. Set during registration; read on unload. +static const char* g_idToggleBankPanel = nullptr; +static const char* g_idInsertSelected = nullptr; +static const char* g_idInsertSelectedConform = nullptr; +static const char* g_idCaptureTrackRealtime = nullptr; +static const char* g_idCancelRealtime = nullptr; +static const char* g_idShowVersion = nullptr; + extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec) { @@ -841,40 +884,42 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( reasampler::designViewUnregisterActions(g_rec); // Tear down the multi-bank action family (B3) — same mirror-unregister. reasampler::bankUnregisterActions(g_rec); + // Each '-command_id' re-presents the SAME interned, channel-qualified pointer + // used at register (g_id*), so the mirror-unregister matches exactly. g_rec->Register("-gaccel", (void*)&g_accelShowVersion); - g_rec->Register("-command_id", - (void*)(REASAMPLER_ACTION_PREFIX "SHOW_VERSION")); + g_rec->Register("-command_id", (void*)g_idShowVersion); g_rec->Register("-gaccel", (void*)&g_accelCancelRealtime); - g_rec->Register("-command_id", - (void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE")); + g_rec->Register("-command_id", (void*)g_idCancelRealtime); g_rec->Register("-gaccel", (void*)&g_accelCaptureTrackRealtime); - g_rec->Register("-command_id", - (void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_TRACK_REALTIME")); + g_rec->Register("-command_id", (void*)g_idCaptureTrackRealtime); g_rec->Register("-gaccel", (void*)&g_accelInsertSelectedConform); - g_rec->Register("-command_id", - (void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM")); + g_rec->Register("-command_id", (void*)g_idInsertSelectedConform); g_rec->Register("-gaccel", (void*)&g_accelInsertSelected); - g_rec->Register("-command_id", - (void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED")); + g_rec->Register("-command_id", (void*)g_idInsertSelected); g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel); - g_rec->Register("-command_id", - (void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL")); - // Mirror-unregister the capture family: gaccel + command_id per row, - // with '-'-prefixed strings (per the contract). The FOREVER-STABLE id - // strings come from the same table used to register them. + g_rec->Register("-command_id", (void*)g_idToggleBankPanel); + // Mirror-unregister the capture family: gaccel + command_id per row, with + // '-'-prefixed strings (per the contract). The command id is re-composed from + // the same suffix + channel prefix used at register — identical string. { const auto& table = reasampler::captureActionTable(); for (std::size_t i = 0; i < table.size(); ++i) { if (i < g_captureAccels.size()) g_rec->Register("-gaccel", (void*)&g_captureAccels[i]); - g_rec->Register("-command_id", (void*)table[i].commandString); + const std::string id = + reasampler::channelCommandId(table[i].commandSuffix); + g_rec->Register("-command_id", (void*)id.c_str()); } } - // Retire the removed M7 command ids (command_id only — we never held a - // gaccel for them this session). Clears stale user keybindings on unload. - for (const char* id : kRetiredCaptureCmdStrings) - g_rec->Register("-command_id", (void*)id); + // Retire the removed M7 command ids (command_id only — we never held a gaccel + // for them this session). Clears stale user keybindings on unload. Composed + // per channel so a beta clears beta-qualified retired ids, stable clears its own. + for (const char* suffix : kRetiredCaptureCmdSuffixes) + { + const std::string id = reasampler::channelCommandId(suffix); + g_rec->Register("-command_id", (void*)id.c_str()); + } } // Destroy the docked window and release cached thumbnails before we drop // the API pointers (DockWindowRemove/DestroyWindow need them live). @@ -903,15 +948,21 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( const auto& table = reasampler::captureActionTable(); g_captureCmdIds.assign(table.size(), 0); g_captureAccels.assign(table.size(), gaccel_register_t{}); + g_captureDescs.assign(table.size(), std::string{}); for (std::size_t i = 0; i < table.size(); ++i) { + // Compose the channel-qualified id (prefix + suffix) and label + // ("ReaSampler[ beta]: " + phrase). The id is interned so unregister re-presents + // the same pointer; the label lives in g_captureDescs for the gaccel's lifetime. const int cmd = - rec->Register("command_id", (void*)table[i].commandString); + rec->Register("command_id", (void*)internCmdId(table[i].commandSuffix)); g_captureCmdIds[i] = cmd; if (cmd) { + g_captureDescs[i] = + reasampler::channelActionName(table[i].descriptionPhrase); g_captureAccels[i].accel.cmd = cmd; - g_captureAccels[i].desc = table[i].description; + g_captureAccels[i].desc = g_captureDescs[i].c_str(); rec->Register("gaccel", (void*)&g_captureAccels[i]); } } @@ -923,14 +974,14 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( reasampler::bankPanelInit(&g_session); // Register the M5 "toggle bank panel" action (command_id -> gaccel -> - // hookcommand + toggleaction for the checked state). - g_cmdToggleBankPanel = rec->Register( - "command_id", - (void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL")); + // hookcommand + toggleaction for the checked state). Id + label are channel-qualified. + g_idToggleBankPanel = internCmdId("TOGGLE_BANK_PANEL"); + g_cmdToggleBankPanel = rec->Register("command_id", (void*)g_idToggleBankPanel); if (g_cmdToggleBankPanel) { + g_descToggleBankPanel = reasampler::channelActionName("toggle bank panel"); g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel; - g_accelToggleBankPanel.desc = "ReaSampler: toggle bank panel"; + g_accelToggleBankPanel.desc = g_descToggleBankPanel.c_str(); rec->Register("gaccel", (void*)&g_accelToggleBankPanel); rec->Register("toggleaction", (void*)&OnToggleAction); } @@ -938,65 +989,66 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( // Register the M6 insert actions (command_id -> gaccel -> hookcommand). Two // variants: native-length (default, no stretch) and the EXPLICIT conform-to- // tempo opt-in. Both read the bank panel selection and place at the edit cursor. - g_cmdInsertSelected = rec->Register( - "command_id", - (void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED")); + g_idInsertSelected = internCmdId("INSERT_SELECTED"); + g_cmdInsertSelected = rec->Register("command_id", (void*)g_idInsertSelected); if (g_cmdInsertSelected) { + g_descInsertSelected = + reasampler::channelActionName("insert selected sample at edit cursor"); g_accelInsertSelected.accel.cmd = g_cmdInsertSelected; - g_accelInsertSelected.desc = - "ReaSampler: insert selected sample at edit cursor"; + g_accelInsertSelected.desc = g_descInsertSelected.c_str(); rec->Register("gaccel", (void*)&g_accelInsertSelected); } - g_cmdInsertSelectedConform = rec->Register( - "command_id", - (void*)(REASAMPLER_ACTION_PREFIX "INSERT_SELECTED_CONFORM")); + g_idInsertSelectedConform = internCmdId("INSERT_SELECTED_CONFORM"); + g_cmdInsertSelectedConform = rec->Register("command_id", (void*)g_idInsertSelectedConform); if (g_cmdInsertSelectedConform) { + g_descInsertSelectedConform = reasampler::channelActionName( + "insert selected sample at edit cursor (conform to tempo)"); g_accelInsertSelectedConform.accel.cmd = g_cmdInsertSelectedConform; - g_accelInsertSelectedConform.desc = - "ReaSampler: insert selected sample at edit cursor (conform to tempo)"; + g_accelInsertSelectedConform.desc = g_descInsertSelectedConform.c_str(); rec->Register("gaccel", (void*)&g_accelInsertSelectedConform); } // Register the "capture selected track (realtime)" action (command_id -> gaccel -> // hookcommand). Realtime sibling of the offline CAPTURE_TRACK scope: records the // selected track's own output in realtime into a hidden temp track, moves it into - // the bank. Dialog-free. NEW FOREVER-STABLE id string. - g_cmdCaptureTrackRealtime = rec->Register( - "command_id", - (void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_TRACK_REALTIME")); + // the bank. Dialog-free. Channel-qualified FOREVER-STABLE id. + g_idCaptureTrackRealtime = internCmdId("CAPTURE_TRACK_REALTIME"); + g_cmdCaptureTrackRealtime = rec->Register("command_id", (void*)g_idCaptureTrackRealtime); if (g_cmdCaptureTrackRealtime) { + g_descCaptureTrackRealtime = + reasampler::channelActionName("capture selected track (realtime)"); g_accelCaptureTrackRealtime.accel.cmd = g_cmdCaptureTrackRealtime; - g_accelCaptureTrackRealtime.desc = - "ReaSampler: capture selected track (realtime)"; + g_accelCaptureTrackRealtime.desc = g_descCaptureTrackRealtime.c_str(); rec->Register("gaccel", (void*)&g_accelCaptureTrackRealtime); } // Cancel-in-flight sibling: aborts a running realtime capture (stop + restore). - // FOREVER-STABLE id string. - g_cmdCancelRealtime = rec->Register( - "command_id", - (void*)(REASAMPLER_ACTION_PREFIX "CANCEL_REALTIME_CAPTURE")); + // Channel-qualified FOREVER-STABLE id. + g_idCancelRealtime = internCmdId("CANCEL_REALTIME_CAPTURE"); + g_cmdCancelRealtime = rec->Register("command_id", (void*)g_idCancelRealtime); if (g_cmdCancelRealtime) { + g_descCancelRealtime = reasampler::channelActionName("cancel realtime capture"); g_accelCancelRealtime.accel.cmd = g_cmdCancelRealtime; - g_accelCancelRealtime.desc = "ReaSampler: cancel realtime capture"; + g_accelCancelRealtime.desc = g_descCancelRealtime.c_str(); rec->Register("gaccel", (void*)&g_accelCancelRealtime); } // Register the Phase V "show version" action (command_id -> gaccel -> hookcommand). // On-demand only — prints the CMake-sourced version to the console when fired; no - // startup print. FOREVER-STABLE id string. - g_cmdShowVersion = rec->Register( - "command_id", - (void*)(REASAMPLER_ACTION_PREFIX "SHOW_VERSION")); + // startup print. Channel-qualified FOREVER-STABLE id; label carries the channel prefix + // so a beta's "show version" is distinguishable from stable's in the Actions list. + g_idShowVersion = internCmdId("SHOW_VERSION"); + g_cmdShowVersion = rec->Register("command_id", (void*)g_idShowVersion); if (g_cmdShowVersion) { + g_descShowVersion = reasampler::channelActionName("show version"); g_accelShowVersion.accel.cmd = g_cmdShowVersion; - g_accelShowVersion.desc = "ReaSampler: show version"; + g_accelShowVersion.desc = g_descShowVersion.c_str(); rec->Register("gaccel", (void*)&g_accelShowVersion); } diff --git a/src/persist.cpp b/src/persist.cpp index b1d56cf..3865afc 100644 --- a/src/persist.cpp +++ b/src/persist.cpp @@ -194,7 +194,7 @@ bool ReaSamplerSession::saveToActiveProject() { // Phase B: the whole book (pool as bank-zero + named banks) is authoritative and // rides in the `banks` key. const std::string banksJson = book_.serialize(); - SetProjExtState(static_cast(proj), kProjExtNamespace, + SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtBanksKey, banksJson.c_str()); // Retire the legacy single-bank `bank_index` key: SetProjExtState with an empty @@ -202,37 +202,40 @@ bool ReaSamplerSession::saveToActiveProject() { // realizes retirement concretely — after any save, a formerly-legacy project // carries `banks` and NO `bank_index`, and going forward the legacy key is never // written. Cheap and idempotent when the key is already absent. - SetProjExtState(static_cast(proj), kProjExtNamespace, + SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtIndexKey, ""); // Additive: the Design-View model rides alongside the banks in its own key. // Independent write — does not disturb the `banks` blob above. const std::string viewJson = view_.serialize(); - SetProjExtState(static_cast(proj), kProjExtNamespace, + SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtViewKey, viewJson.c_str()); // Additive: the docked panel's tail setting rides alongside in its own key, so the // tail choice travels inside the .rpp. Independent write — does not disturb the // bank_index or view_state above. const std::string tailJson = serializeTailSetting(tail_); - SetProjExtState(static_cast(proj), kProjExtNamespace, + SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtTailKey, tailJson.c_str()); // Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own // `owned_files` key. Independent write — does not disturb the blobs above. Written // on EVERY save so a capture's manifest record survives Save / Save-As / reopen, // and so the manifest and the bank stay in lockstep on disk (both persisted by the - // same saveToActiveProject the capture add-path calls). + // same saveToActiveProject the capture add-path calls). Uses the channel-derived + // namespace (projExtNamespace) like its sibling keys — V4 isolation applies here too. const std::string ownedJson = owned_.serialize(); - SetProjExtState(static_cast(proj), kProjExtNamespace, + SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtOwnedKey, ownedJson.c_str()); - // Phase V (V1): stamp the WRITING version — the build producing this save — under the - // version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty - // stay paired (no drifting ad-hoc SetProjExtState). appVersion() is the one CMake- - // sourced constant; every save records the exact current build into the .rpp. - SetProjExtState(static_cast(proj), kProjExtNamespace, - kProjExtVersionKey, appVersion().c_str()); + // Phase V (V1/V4): stamp the WRITING version — the build producing this save — under + // the version key, on the SAME seam as the keys above so the stamp and MarkProjectDirty + // stay paired (no drifting ad-hoc SetProjExtState). stampVersion() (NOT appVersion()) is + // the NUMERIC TRIPLE ONLY on both channels — no "-beta" suffix — so the stamp parses as + // Stamped on read-back and stays byte-identical to stable regardless of channel; the + // channel is already carried by the isolated namespace (projExtNamespace) this writes to. + SetProjExtState(static_cast(proj), projExtNamespace(), + kProjExtVersionKey, stampVersion().c_str()); MarkProjectDirty(static_cast(proj)); return true; @@ -249,7 +252,7 @@ namespace { ViewModeModel loadViewModel(ReaProject* proj) { if (!proj) return ViewModeModel{}; const std::string viewJson = - getProjExtStateString(proj, kProjExtNamespace, kProjExtViewKey); + getProjExtStateString(proj, projExtNamespace(), kProjExtViewKey); if (viewJson.empty()) return ViewModeModel{}; // no stored view state -> default std::optional loaded = ViewModeModel::deserialize(viewJson); if (!loaded) { @@ -266,7 +269,7 @@ ViewModeModel loadViewModel(ReaProject* proj) { TailSetting loadTailSetting(ReaProject* proj) { if (!proj) return TailSetting{}; const std::string tailJson = - getProjExtStateString(proj, kProjExtNamespace, kProjExtTailKey); + getProjExtStateString(proj, projExtNamespace(), kProjExtTailKey); if (tailJson.empty()) return TailSetting{}; // no stored setting -> default std::optional loaded = deserializeTailSetting(tailJson); if (!loaded) { @@ -285,7 +288,7 @@ TailSetting loadTailSetting(ReaProject* proj) { OwnedFileManifest loadOwnedManifest(ReaProject* proj) { if (!proj) return OwnedFileManifest{}; const std::string ownedJson = - getProjExtStateString(proj, kProjExtNamespace, kProjExtOwnedKey); + getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey); if (ownedJson.empty()) return OwnedFileManifest{}; // no stored manifest -> empty std::optional loaded = OwnedFileManifest::deserialize(ownedJson); if (!loaded) { @@ -329,7 +332,7 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // an error). getProjExtStateString returns "" for an absent key, which is exactly the // PreVersioning input classifyWritingVersion expects. proj == nullptr -> "" -> default. writingVersion_ = classifyWritingVersion( - proj ? getProjExtStateString(static_cast(proj), kProjExtNamespace, + proj ? getProjExtStateString(static_cast(proj), projExtNamespace(), kProjExtVersionKey) : std::string{}); @@ -347,7 +350,7 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // warning. A malformed `banks` degrades to an empty book and does NOT fall back to // the stale legacy key (which would resurrect superseded single-bank state). const std::string banksJson = - getProjExtStateString(static_cast(proj), kProjExtNamespace, + getProjExtStateString(static_cast(proj), projExtNamespace(), kProjExtBanksKey); if (!banksJson.empty()) { std::optional loaded = BankBook::deserialize(banksJson); @@ -362,7 +365,7 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi // by BankBook's parse-time promotion. loadFromPersisted covers the legacy-or- // empty tail; passing "" for banksJson takes exactly that branch. const std::string legacyJson = - getProjExtStateString(static_cast(proj), kProjExtNamespace, + getProjExtStateString(static_cast(proj), projExtNamespace(), kProjExtIndexKey); book_ = BankBook::loadFromPersisted(std::string{}, legacyJson); } @@ -391,7 +394,7 @@ std::string ensureProjectGuid(void* proj, const std::string& rppPath, if (!proj || rppPath.empty()) return {}; // unsaved -> cannot store a GUID if (!currentGuid.empty()) return currentGuid; const std::string minted = genProjectGuidString(); - SetProjExtState(static_cast(proj), kProjExtNamespace, + SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtGuidKey, minted.c_str()); return minted; } @@ -416,7 +419,7 @@ void ReaSamplerSession::poll() { void* proj = readActiveProject(rppPath); const std::string currentGuid = proj ? getProjExtStateString(static_cast(proj), - kProjExtNamespace, kProjExtGuidKey) + projExtNamespace(), kProjExtGuidKey) : std::string{}; if (!primed_) { @@ -482,7 +485,7 @@ void ReaSamplerSession::poll() { if (proj && !sameProjectObject && !currentGuid.empty() && currentGuid == lastGuid_ && !rppPath.empty()) { const std::string fresh = genProjectGuidString(); - SetProjExtState(static_cast(proj), kProjExtNamespace, + SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtGuidKey, fresh.c_str()); MarkProjectDirty(static_cast(proj)); loadFromProject(proj, projectDirOf(rppPath)); @@ -521,7 +524,7 @@ void ReaSamplerSession::poll() { // to the new .rpp on the next normal save / close-prompt. const std::string fresh = genProjectGuidString(); if (proj) { - SetProjExtState(static_cast(proj), kProjExtNamespace, + SetProjExtState(static_cast(proj), projExtNamespace(), kProjExtGuidKey, fresh.c_str()); MarkProjectDirty(static_cast(proj)); } diff --git a/src/persist.h b/src/persist.h index b61d03d..92851ee 100644 --- a/src/persist.h +++ b/src/persist.h @@ -28,9 +28,16 @@ namespace reasampler { -// The ext-state namespace the index JSON is stored under. FOREVER-STABLE once -// shipped: changing it orphans every already-saved project's index. -inline constexpr const char* kProjExtNamespace = "reasampler"; +// The ext-state namespace every ReaSampler key is stored under. CHANNEL-DERIVED (Phase V, +// V4): the pure app_version module owns the one channel-qualified string — "reasampler" on +// stable (byte-identical to the pre-V4 build) or "reasampler_beta" on the isolated beta +// build. FOREVER-STABLE per channel once shipped: changing either orphans every already- +// saved project's state. Beta reads/writes ONLY its own namespace — a project saved by +// stable shows empty/default state in beta and vice versa; that isolation is the accepted +// V4 safety property (no cross-namespace read, migration, or fallback), not a bug. +// Returns const char* (not a constexpr literal) because the string is channel-derived at +// build time; the accessor is the single call point for all persist reads/writes below. +inline const char* projExtNamespace() { return extStateNamespace().c_str(); } // The RETIRED legacy ext-state key: pre-multi-bank projects stored the whole // serialized BankIndex here (single bank). Phase B2 no longer WRITES it — on save diff --git a/src/render_settings.cpp b/src/render_settings.cpp index 6a38129..041cfa9 100644 --- a/src/render_settings.cpp +++ b/src/render_settings.cpp @@ -207,14 +207,15 @@ const std::vector& captureActionTable() { // main.cpp); the CAPTURE_MASTER scope action is REMOVED (its id is likewise // mirror-unregistered) — to capture the master you render a track. static const std::vector table = { - // Item scope — item/take FX only. - {"CEREBELLUM_REASAMPLER_CAPTURE_ITEM", - "ReaSampler: capture selected item(s)", "item", + // Item scope — item/take FX only. Suffix + phrase are channel-agnostic; the shell + // composes the FOREVER-STABLE id (prefix + "CAPTURE_ITEM") and the display name. + {"CAPTURE_ITEM", + "capture selected item(s)", "item", CaptureScope::Item}, // Track scope — item FX + the track's own FX. - {"CEREBELLUM_REASAMPLER_CAPTURE_TRACK", - "ReaSampler: capture selected track(s)", "track", + {"CAPTURE_TRACK", + "capture selected track(s)", "track", CaptureScope::Track}, }; return table; diff --git a/src/render_settings.h b/src/render_settings.h index ab79da6..8d337eb 100644 --- a/src/render_settings.h +++ b/src/render_settings.h @@ -233,13 +233,21 @@ RazorRange razorUnionBounds(const std::vector& ranges); // at fire time (see tail_control + bank_panel), so a single pair of actions covers // every tail state. Bounded, discoverable, NO dialogs (the tool's no-clutter ethos). // -// commandString is FOREVER-STABLE (user keybindings key off it) — never change a -// shipped value. baseName feeds the file stem (sanitized by capture_paths). +// Phase V (V4): the row stores the channel-AGNOSTIC pieces — a command-id SUFFIX (the +// tail after the family prefix) and a description PHRASE (the label after the "ReaSampler: +// " lead). The registering shell composes the full, channel-qualified id/name via +// app_version's channelCommandId / channelActionName (commandIdPrefix + suffix / +// actionDisplayPrefix + phrase). This keeps the pure table free of any channel branch: +// stable rebuilds the exact shipped id "CEREBELLUM_REASAMPLER_CAPTURE_TRACK" from +// prefix + "CAPTURE_TRACK"; beta yields "CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK". +// +// commandSuffix is FOREVER-STABLE (user keybindings key off the composed id) — never +// change a shipped value. baseName feeds the file stem (sanitized by capture_paths). struct CaptureActionDef { - const char* commandString; // CEREBELLUM_REASAMPLER_… FOREVER-STABLE id string - const char* description; // Actions-list label - const char* baseName; // file-stem base for this capture - CaptureScope scope; // FX scope (item / track) + const char* commandSuffix; // e.g. "CAPTURE_TRACK" — FOREVER-STABLE (composed w/ prefix) + const char* descriptionPhrase; // e.g. "capture selected track(s)" — Actions-list phrase + const char* baseName; // file-stem base for this capture + CaptureScope scope; // FX scope (item / track) }; // The capture-action table. Iterated by main.cpp to register the family and route diff --git a/src/version_generated.h.in b/src/version_generated.h.in index b024847..769c30e 100644 --- a/src/version_generated.h.in +++ b/src/version_generated.h.in @@ -1,9 +1,23 @@ #pragma once // version_generated.h.in — configure_file TEMPLATE. CMake substitutes @REASAMPLER_VERSION@ -// (the ONE source-of-truth variable in CMakeLists.txt) and writes the result to the build -// tree as version_generated.h. DO NOT edit the generated header — edit REASAMPLER_VERSION -// in CMakeLists.txt; that single edit re-generates this and re-threads the exact string -// (leading zero preserved verbatim) into the binary constant, the ext-state stamp, and the -// show-version action. See app_version.h / .cpp. +// (the ONE version source-of-truth variable in CMakeLists.txt) and @REASAMPLER_CHANNEL_IS_BETA@ +// (0 for the stable/default build, 1 for -DREASAMPLER_CHANNEL=beta) and writes the result to +// the build tree as version_generated.h. DO NOT edit the generated header — edit +// REASAMPLER_VERSION / the channel flag in CMakeLists.txt; that single edit re-generates this +// and re-threads the exact string (leading zero preserved verbatim) plus the channel bit into +// the binary constants, the ext-state namespace/stamp, the command-id prefix, and the panel +// readout. See app_version.h / .cpp. +// +// Two channel-derived facts flow from here and NOWHERE ELSE, so "what makes a beta a beta" is +// one auditable definition (Phase V, V4 — beta-in-isolation): +// * REASAMPLER_VERSION_STRING — the numeric semver triple, IDENTICAL on both channels +// (leading zero preserved). This is the ext-state STAMP value: it stays the numeric triple +// so parseVersion classifies it as Stamped when its own channel reads it back, and it is +// byte-identical to stable's stamp regardless of channel (the channel is carried by the +// ISOLATED namespace, not baked into the stamp — see app_version.h). +// * REASAMPLER_CHANNEL_IS_BETA — 0 (stable, the default absent-flag build) or 1 (beta). The +// one bit app_version fans out into every channel-qualified identity (display suffix, +// namespace, command-id prefix, action-name prefix, binary/dock idents). #define REASAMPLER_VERSION_STRING "@REASAMPLER_VERSION@" +#define REASAMPLER_CHANNEL_IS_BETA @REASAMPLER_CHANNEL_IS_BETA@ diff --git a/tests/test_app_version.cpp b/tests/test_app_version.cpp index 97d4c20..4f05eab 100644 --- a/tests/test_app_version.cpp +++ b/tests/test_app_version.cpp @@ -17,13 +17,94 @@ static int g_fail = 0; std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) // --- appVersion: exact-string fidelity, leading zero preserved ---------------- +// +// The channel is a COMPILE-TIME fact (REASAMPLER_CHANNEL_IS_BETA, threaded through the same +// configure_file'd header): the stable `build` tree compiles these tests with the flag = 0, +// the `build-beta` tree with = 1. So each config's ctest run validates ITS OWN channel's +// derivation. The channel-dependent assertions below branch on isBeta() so the ONE test +// source is correct in both configs — and each branch would fail if the derivation regressed +// (a beta build rendering "0.9.01" without the suffix, or stable rendering "-beta", trips it). static void testVersionConstantRendersExactString() { - // The Daniel-fixed string: EXACTLY "0.9.01", two-digit zero-padded patch. This is - // the whole point of sourcing the STRING (not reconstructing from numeric components): - // a normalized "0.9.1" here would be a leading-zero-fidelity regression, and this - // assertion fails against the actual CMake-configured value, not a re-derivation. - CHECK(appVersion() == "0.9.01"); + // The Daniel-fixed base string: EXACTLY "0.9.01", two-digit zero-padded patch — the + // numeric triple, IDENTICAL on both channels (it is also the stamp value). This is the + // whole point of sourcing the STRING (not reconstructing from numeric components): a + // normalized "0.9.1" here would be a leading-zero-fidelity regression. stampVersion() + // is the pure numeric triple regardless of channel. + CHECK(stampVersion() == "0.9.01"); +} + +// --- channel-derived rendering (V4) ------------------------------------------- + +static void testChannelDerivedRendering() { + // The DISPLAY render. Stable: exactly the numeric string. Beta: numeric + "-beta" + // (a plain suffix, V2). This is the show-version + panel-readout value. Each branch is + // the assertion the OTHER config's build would need to fail — i.e. a stable build that + // wrongly rendered "-beta", or a beta build that dropped it, is caught here. + if (isBeta()) { + CHECK(channel() == Channel::Beta); + CHECK(appVersion() == "0.9.01-beta"); + } else { + CHECK(channel() == Channel::Stable); + CHECK(appVersion() == "0.9.01"); + } + // The STAMP value is the numeric triple on BOTH channels — never suffixed — so it stays + // classifiable (see the stamp-classifiability test) and byte-identical to stable. + CHECK(stampVersion() == "0.9.01"); +} + +static void testChannelDerivedIdentityStrings() { + // Namespace, command-id prefix, action-name prefix, binary + dock idents all fork from + // the one channel bit. Stable values are BYTE-IDENTICAL to the pre-V4 build — any drift + // in the stable branch is a shipped-identity defect. + if (isBeta()) { + CHECK(extStateNamespace() == "reasampler_beta"); + CHECK(commandIdPrefix() == "CEREBELLUM_REASAMPLER_BETA_"); + CHECK(actionDisplayPrefix() == "ReaSampler beta: "); + CHECK(binaryName() == "reaper_reasampler_beta"); + CHECK(dockTitle() == "ReaSampler Bank beta"); + CHECK(dockIdent() == "reasampler_bank_panel_beta"); + } else { + CHECK(extStateNamespace() == "reasampler"); + CHECK(commandIdPrefix() == "CEREBELLUM_REASAMPLER_"); + CHECK(actionDisplayPrefix() == "ReaSampler: "); + CHECK(binaryName() == "reaper_reasampler"); + CHECK(dockTitle() == "ReaSampler Bank"); + CHECK(dockIdent() == "reasampler_bank_panel"); + } +} + +static void testChannelQualifiedIdAndNameComposition() { + // The two composition helpers the shells funnel through. A representative shipped id + // (CAPTURE_TRACK) and phrase must compose to the exact channel-qualified strings — this + // is what guarantees stable rebuilds its shipped id and beta gets the isolated one. + if (isBeta()) { + CHECK(channelCommandId("CAPTURE_TRACK") == "CEREBELLUM_REASAMPLER_BETA_CAPTURE_TRACK"); + CHECK(channelActionName("capture selected track(s)") == + "ReaSampler beta: capture selected track(s)"); + } else { + CHECK(channelCommandId("CAPTURE_TRACK") == "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); + CHECK(channelActionName("capture selected track(s)") == + "ReaSampler: capture selected track(s)"); + } +} + +static void testStampClassifiesAsStampedOnOwnChannel() { + // The V4 stamp-classifiability requirement: the value a channel WRITES (stampVersion()) + // must classify as Stamped when that same channel reads it back — on BOTH channels. A + // "-beta"-suffixed stamp would classify as Unknown, so this fails if a build ever stamped + // appVersion() instead of stampVersion(). + WritingVersion wv = classifyWritingVersion(stampVersion()); + CHECK(wv.kind == WritingVersion::Kind::Stamped); + CHECK(wv.raw == "0.9.01"); + CHECK(wv.parsed.major == 0 && wv.parsed.minor == 9 && wv.parsed.patch == 1); + + // The OTHER channel's raw stamp value is handled without throwing per the design: since + // both channels stamp the identical numeric triple, the other channel's value is the same + // "0.9.01" and also classifies Stamped. (The DISPLAY string "0.9.01-beta", by contrast, + // is intentionally NOT the stamp value — confirm it classifies Unknown, proving why the + // stamp must stay the numeric triple.) + CHECK(classifyWritingVersion("0.9.01-beta").kind == WritingVersion::Kind::Unknown); } // --- parseVersion: well-formed, leading zeros, and rejection ------------------ @@ -122,6 +203,10 @@ static void testStampedStampIsOrderableAgainstCurrent() { int main() { testVersionConstantRendersExactString(); + testChannelDerivedRendering(); + testChannelDerivedIdentityStrings(); + testChannelQualifiedIdAndNameComposition(); + testStampClassifiesAsStampedOnOwnChannel(); testParseWellFormed(); testParseRejectsMalformed(); testOrderingByPatch(); diff --git a/tests/test_render_settings.cpp b/tests/test_render_settings.cpp index 57a30d8..62001c1 100644 --- a/tests/test_render_settings.cpp +++ b/tests/test_render_settings.cpp @@ -252,11 +252,15 @@ static void testTableHasBothScopes() { std::set ids; int item = 0, track = 0; for (const auto& def : table) { - // Every id is a non-empty CEREBELLUM_REASAMPLER_ string and is UNIQUE - // (duplicate ids would collide on registration). - std::string id = def.commandString; - CHECK(id.rfind("CEREBELLUM_REASAMPLER_", 0) == 0); - CHECK(ids.insert(id).second); // false if duplicate + // Every command SUFFIX (Phase V, V4 — the channel prefix is prepended by the shell) + // is a non-empty, UNIQUE string (duplicate suffixes would collide once composed). + std::string suffix = def.commandSuffix; + CHECK(!suffix.empty()); + // The suffix is NOT prefixed with the channel family here — that is composed at + // register time. A leftover "CEREBELLUM_REASAMPLER_" in the table would be a + // double-prefix bug, so assert its ABSENCE. + CHECK(suffix.rfind("CEREBELLUM_REASAMPLER_", 0) != 0); + CHECK(ids.insert(suffix).second); // false if duplicate // Every scope resolves to a supported offline source. CHECK(renderSettingsFor(sourceModeForScope(def.scope), 1.0).supported); @@ -269,16 +273,17 @@ static void testTableHasBothScopes() { } static void testScopeActionIdsAreTheShippedStrings() { - // Pin the shipped CAPTURE_ITEM / CAPTURE_TRACK ids so a future edit that silently - // changes them (breaking user keybindings) fails the gate. + // Pin the shipped CAPTURE_ITEM / CAPTURE_TRACK SUFFIXES so a future edit that silently + // changes them (breaking user keybindings once composed with the channel prefix) fails + // the gate. The full stable id is prefix + suffix ("CEREBELLUM_REASAMPLER_CAPTURE_ITEM"). const auto& table = captureActionTable(); std::string itemId, trackId; for (const auto& def : table) { - if (def.scope == CaptureScope::Item) itemId = def.commandString; - if (def.scope == CaptureScope::Track) trackId = def.commandString; + if (def.scope == CaptureScope::Item) itemId = def.commandSuffix; + if (def.scope == CaptureScope::Track) trackId = def.commandSuffix; } - CHECK(itemId == "CEREBELLUM_REASAMPLER_CAPTURE_ITEM"); - CHECK(trackId == "CEREBELLUM_REASAMPLER_CAPTURE_TRACK"); + CHECK(itemId == "CAPTURE_ITEM"); + CHECK(trackId == "CAPTURE_TRACK"); } int main() { From b950926969ca5eac9e9cdc411088380dd1d94ce2 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 26 Jul 2026 16:14:11 -0400 Subject: [PATCH 4/4] chore: ignore /build-beta/ directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 73a674c..18f66b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /build/ +/build-beta/ /.claude/worktrees/ *.dll *.dylib