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() {