Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,41 @@ Modular ImGui-based configuration interface with specialized renderers for diffe

Feature versions are automatically extracted from `.ini` files and compiled into `FeatureVersions.h` at build time for backward compatibility checking.

### Release Stages (Alpha / Beta)

Features can declare a release-maturity stage in their `.ini` `[Info]` section. This drives the default-enabled state, a UI marker, and the version-audit policy.

**Declaring a stage** (in `features/<Feature>/Shaders/Features/<Feature>.ini`):

```ini
[Info]
Version = 0-2-0
Beta = True
```

- Flags: `Alpha` or `Beta`. Truthy values are `true`, `1`, `yes`, `on` (case-insensitive). Absent or non-truthy means full **Release**.
- `Alpha` takes precedence over `Beta` when both are set.
- The flag line must start the line (after optional whitespace). The CMake parser in `CMakeLists.txt` and the Python parser in `tools/feature_version_audit.py` are both line-anchored; **keep these two regexes in sync** so build-time classification and audit enforcement agree.

**Build-time baking**: `CMakeLists.txt` collects flagged features into `FEATURE_ALPHA_NAMES` / `FEATURE_BETA_NAMES` in the generated `FeatureVersions.h` (same mechanism as `FEATURE_CORE_NAMES`).

**Runtime API** (`src/Feature.h`):

- `Feature::GetReleaseStage()` returns `ReleaseStage::{Release, Beta, Alpha}` by looking the short name up in the baked sets. Resolve it once and pass it around; it is not cached.
- `IsAlpha()` / `IsBeta()` convenience predicates.
- `static GetReleaseStageTag(ReleaseStage)` returns the localized `[ALPHA]` / `[BETA]` marker (empty for Release). It takes the stage so callers that already resolved it avoid a redundant lookup.
- `IsDisabledByDefault()` returns `true` for any non-Release stage, so **Alpha/Beta features start disabled on first install**. Users can still enable them via the "Disable at Boot" menu. Do not add a redundant `IsDisabledByDefault` override on a feature that already carries a stage flag.

**UI**: `FeatureListRenderer` draws the stage tag next to the feature name. Alpha uses the theme `StatusPalette.Error` color, Beta uses `StatusPalette.Warning`.

**Versioning convention** (enforced by `tools/feature_version_audit.py`):

- Pre-release features use `0.x` versions. Entering pre-release from a release/fresh baseline: Beta starts at `0-2-0`, Alpha at `0-1-0`.
- `alpha -> beta` bumps the minor and resets the patch.
- Within the same pre-release stage, normal semver applies inside `0.x`.
- A breaking change (`feat!:` / `BREAKING CHANGE:`) on a pre-release feature **promotes it to release `1-0-0` and strips the Alpha/Beta flag**. `--apply-bumps` performs both the version bump and the flag removal automatically.
- Stage transitions are exact-match enforced (they may legitimately lower the version, e.g. release `1.x` -> beta `0-2-0`), unlike the lenient `>` check used within a stage.

## Key Development Patterns

### Memory Management
Expand Down
27 changes: 27 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,31 @@ foreach(FEATURE_PATH ${FEATURE_CONFIG_FILES})
)
endif()

# Detect release stage from Alpha/Beta flags. Alpha takes precedence when
# both are set. A truthy value is required; absent or non-truthy means the
Comment thread
SkrubbySkrubInAShrub marked this conversation as resolved.
# feature is a full Release.
# Anchor to a line start ((^|[\r\n]); CMake regex has no multiline mode) so a
# settings key ending in "alpha"/"beta" (e.g. WaterAlpha = 1) is not mistaken
# for a stage flag. Keep in sync with the line-anchored match in
# tools/feature_version_audit.py.
# NOTE: guard on the match-result variable because CMAKE_MATCH_2 (the value)
# retains its prior value (the version major, above) when a REGEX MATCH misses.
set(_STAGE_ALPHA "")
set(_STAGE_BETA "")
string(REGEX MATCH "(^|[\r\n])[ \t]*[Aa]lpha[ \t]*=[ \t]*([A-Za-z0-9]+)" _STAGE_ALPHA_MATCH "${CONFIG_VALUE}")
if(_STAGE_ALPHA_MATCH)
string(TOLOWER "${CMAKE_MATCH_2}" _STAGE_ALPHA)
endif()
string(REGEX MATCH "(^|[\r\n])[ \t]*[Bb]eta[ \t]*=[ \t]*([A-Za-z0-9]+)" _STAGE_BETA_MATCH "${CONFIG_VALUE}")
if(_STAGE_BETA_MATCH)
string(TOLOWER "${CMAKE_MATCH_2}" _STAGE_BETA)
endif()
if(_STAGE_ALPHA MATCHES "^(true|1|yes|on)$")
list(APPEND FEATURE_ALPHA_NAMES "\t\t\"${FEATURE}\"sv")
elseif(_STAGE_BETA MATCHES "^(true|1|yes|on)$")
list(APPEND FEATURE_BETA_NAMES "\t\t\"${FEATURE}\"sv")
endif()

# Detect core features by checking for the CORE marker file in the
# feature root (features/<Folder>/CORE). FEATURE_PATH is
# features/<Folder>/Shaders/Features/<ShortName>.ini, so the feature
Expand All @@ -253,6 +278,8 @@ set_property(

string(REPLACE ";" ",\n" FEATURE_VERSIONS "${FEATURE_VERSIONS}")
string(REPLACE ";" ",\n" FEATURE_CORE_NAMES "${FEATURE_CORE_NAMES}")
string(REPLACE ";" ",\n" FEATURE_ALPHA_NAMES "${FEATURE_ALPHA_NAMES}")
string(REPLACE ";" ",\n" FEATURE_BETA_NAMES "${FEATURE_BETA_NAMES}")

configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/cmake/FeatureVersions.h.in
Expand Down
10 changes: 10 additions & 0 deletions cmake/FeatureVersions.h.in
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,14 @@ namespace FeatureVersions
{
@FEATURE_CORE_NAMES@
};

static const std::unordered_set<std::string_view> FEATURE_ALPHA_NAMES
{
@FEATURE_ALPHA_NAMES@
};

static const std::unordered_set<std::string_view> FEATURE_BETA_NAMES
{
@FEATURE_BETA_NAMES@
};
}
4 changes: 2 additions & 2 deletions features/Unified Water/Shaders/Features/UnifiedWater.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[Info]
Version = 1-0-2

Version = 0-2-0
Beta = True
2 changes: 2 additions & 0 deletions package/SKSE/Plugins/CommunityShaders/Translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1715,6 +1715,8 @@
"menu.features.setting_change_warning_title": "Setting Change Warning",
"menu.features.settings_adjusted_warning": "Some of your settings have been automatically adjusted due to feature incompatibilities.",
"menu.features.settings_hidden_disabled": "Feature settings are hidden because this feature is disabled at boot.",
"menu.features.tag_alpha": "[ALPHA]",
"menu.features.tag_beta": "[BETA]",
"menu.features.unloaded_features": "Unloaded Features",
"menu.footer.d3d12_swap_chain": "D3D12 Swap Chain: {status}",
"menu.footer.game_version": "Game Version: {runtime} {version}",
Expand Down
12 changes: 12 additions & 0 deletions src/Feature.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,18 @@ std::string Feature::GetDisplayCategory() const
return std::string(category);
}

std::string Feature::GetReleaseStageTag(ReleaseStage stage)
{
switch (stage) {
case ReleaseStage::Alpha:
return T("menu.features.tag_alpha", "[ALPHA]");
case ReleaseStage::Beta:
return T("menu.features.tag_beta", "[BETA]");
default:
return {};
}
}

void Feature::DrawUnloadedUI()
{
// Prioritize detailed failure message if available
Expand Down
37 changes: 34 additions & 3 deletions src/Feature.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,43 @@ struct Feature
*/
virtual std::string_view GetCategory() const { return FeatureCategories::kOther; }

/**
* Release maturity stage, declared via the feature .ini (Alpha/Beta flags) and
* baked into FeatureVersions.h at build time. Alpha takes precedence over Beta.
*/
enum class ReleaseStage
{
Release,
Beta,
Alpha
};

virtual ReleaseStage GetReleaseStage() const
{
const auto name = const_cast<Feature*>(this)->GetShortName();
if (FeatureVersions::FEATURE_ALPHA_NAMES.contains(name))
return ReleaseStage::Alpha;
if (FeatureVersions::FEATURE_BETA_NAMES.contains(name))
return ReleaseStage::Beta;
return ReleaseStage::Release;
}

bool IsAlpha() const { return GetReleaseStage() == ReleaseStage::Alpha; }
bool IsBeta() const { return GetReleaseStage() == ReleaseStage::Beta; }

/**
* Localized stage marker shown after the feature name ("[ALPHA]", "[BETA]"),
* empty for release features. Takes the stage so callers that already resolved
* it (see GetReleaseStage) avoid a redundant lookup.
*/
static std::string GetReleaseStageTag(ReleaseStage stage);

/**
* Whether the feature is disabled at boot by default (before any user override).
* Features that override this to return true will start disabled on first install;
* users can still enable them via the "Disable at Boot" menu.
* Alpha and Beta features start disabled on first install; users can still enable
* them via the "Disable at Boot" menu.
*/
virtual bool IsDisabledByDefault() const { return false; }
virtual bool IsDisabledByDefault() const { return GetReleaseStage() != ReleaseStage::Release; }

/**
* Whether the feature will show up in the GUI menu
Expand Down
1 change: 0 additions & 1 deletion src/Features/UnifiedWater.h
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ struct UnifiedWater : OverlayFeature
virtual void RestoreDefaultSettings() override;

virtual bool IsCore() const override { return true; }
virtual bool IsDisabledByDefault() const override { return true; }

virtual void PostPostLoad() override;

Expand Down
49 changes: 45 additions & 4 deletions src/Menu/FeatureListRenderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ namespace
return std::find(CORE_MENU_NAMES.begin(), CORE_MENU_NAMES.end(), menuName) != CORE_MENU_NAMES.end();
}

// Color for the [ALPHA]/[BETA] stage marker. Alpha (less stable) reads as an error,
// Beta as a warning.
ImVec4 StageTagColor(Feature::ReleaseStage stage)
{
const auto& statusPalette = globals::menu->GetTheme().StatusPalette;
return stage == Feature::ReleaseStage::Alpha ? statusPalette.Error : statusPalette.Warning;
}

/**
* @brief Determines if the left feature panel should be visible based on auto-hide settings and mouse position
* @return true if panel should be visible, false if it should be hidden
Expand Down Expand Up @@ -166,7 +174,7 @@ namespace
* @param description Short description shown below the title (single line, truncated if too long)
* @return The height of just the title line (for button alignment)
*/
float DrawFeatureHeader(const std::string& featureName, const std::string& version, const std::string& description = "")
float DrawFeatureHeader(const std::string& featureName, const std::string& version, const std::string& description = "", const std::string& stageTag = "", ImVec4 stageColor = {})
{
auto& themeSettings = globals::menu->GetTheme();
auto& palette = themeSettings.Palette;
Expand Down Expand Up @@ -197,6 +205,31 @@ namespace
// Store the title-only height for return value
float titleOnlyHeight = titleSize.y;

// Running x for bottom-aligned annotations (stage tag, then version) to the right of the title
float annotationX = startPos.x + titleSize.x + ImGui::GetStyle().ItemSpacing.x;

// Draw stage marker ([ALPHA]/[BETA]) on same line, bottom-aligned
if (!stageTag.empty()) {
ImVec2 tagSize;
{
MenuFonts::FontRoleGuard bodyGuard(Menu::FontRole::Body);
tagSize = ImGui::CalcTextSize(stageTag.c_str());
tagSize.x *= titleScale;
tagSize.y *= titleScale;
}

ImGui::SetCursorScreenPos(ImVec2(annotationX, startPos.y + titleSize.y - tagSize.y));
{
MenuFonts::FontRoleGuard bodyGuard(Menu::FontRole::Body);
ImGui::SetWindowFontScale(titleScale);
ImGui::TextColored(stageColor, "%s", stageTag.c_str());
ImGui::SetWindowFontScale(1.0f);
}

annotationX += tagSize.x + ImGui::GetStyle().ItemSpacing.x;
ImGui::SetCursorScreenPos(ImVec2(startPos.x, startPos.y + titleSize.y + ImGui::GetStyle().ItemSpacing.y * 0.25f));
}

// Draw version on same line with Body font, bottom-aligned if version exists
if (!version.empty()) {
// Format version: replace dashes with dots for consistency
Expand All @@ -212,8 +245,8 @@ namespace
versionSize.y *= titleScale;
}

// Position version text: right of title, bottom-aligned
float versionX = startPos.x + titleSize.x + ImGui::GetStyle().ItemSpacing.x;
// Position version text: right of the stage tag (or title), bottom-aligned
float versionX = annotationX;
float versionY = startPos.y + titleSize.y - versionSize.y;

ImGui::SetCursorScreenPos(ImVec2(versionX, versionY));
Expand Down Expand Up @@ -582,6 +615,12 @@ void FeatureListRenderer::ListMenuVisitor::operator()(Feature* feat)
}
ImGui::PopStyleColor();

// Display the stage marker behind the name, regardless of loaded state
if (const auto stage = feat->GetReleaseStage(); stage != Feature::ReleaseStage::Release) {
ImGui::SameLine();
ImGui::TextColored(StageTagColor(stage), "%s", Feature::GetReleaseStageTag(stage).c_str());
}

// Display version if loaded
if (isLoaded) {
ImGui::SameLine();
Expand Down Expand Up @@ -682,7 +721,9 @@ void FeatureListRenderer::DrawMenuVisitor::RenderFeatureHeader(Feature* feat, bo

// Draw feature title, version, and description on the left
// Returns title-only height for button alignment
float titleOnlyHeight = DrawFeatureHeader(feat->GetDisplayName(), isLoaded ? feat->version : "", description);
const auto stage = feat->GetReleaseStage();
const std::string stageTag = Feature::GetReleaseStageTag(stage); // empty for Release; color unused when tag is empty
float titleOnlyHeight = DrawFeatureHeader(feat->GetDisplayName(), isLoaded ? feat->version : "", description, stageTag, StageTagColor(stage));

// Save cursor position after header (for restoring after buttons are drawn)
ImVec2 cursorPosAfterHeader = ImGui::GetCursorScreenPos();
Expand Down
Loading
Loading