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.
This commit is contained in:
2026-07-26 15:16:55 -04:00
parent 791a9c60eb
commit f6dddbf5a3
8 changed files with 409 additions and 2 deletions
+76
View File
@@ -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 <string>
#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<Version> 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<int>(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<int>(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<Version> 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
+73
View File
@@ -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 <optional>
#include <string>
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<Version> 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
+31
View File
@@ -24,6 +24,7 @@
#include <vector>
#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
+18
View File
@@ -79,6 +79,7 @@
#include <string>
#include <vector>
#include "app_version.h"
#include "capture_paths.h"
#define REAPERAPI_MINIMAL
@@ -226,6 +227,13 @@ bool ReaSamplerSession::saveToActiveProject() {
SetProjExtState(static_cast<ReaProject*>(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<ReaProject*>(proj), kProjExtNamespace,
kProjExtVersionKey, appVersion().c_str());
MarkProjectDirty(static_cast<ReaProject*>(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<ReaProject*>(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<ReaProject*>(proj), kProjExtNamespace,
kProjExtVersionKey)
: std::string{});
if (!proj) {
book_ = BankBook{};
return;
+24
View File
@@ -19,6 +19,7 @@
#include <string>
#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
+9
View File
@@ -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@"