From f91737c9c230f34859ac3904874272ce4ff34173 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:19:22 +0100 Subject: [PATCH 001/100] Add native in-game Mods options tab scaffold with MiscSettingsScreen diagnostic hooks --- src/hades2/mod_settings/mod_settings.cpp | 45 ++++++++++++++++++++++++ src/hades2/mod_settings/mod_settings.hpp | 6 ++++ src/main.cpp | 4 +++ 3 files changed, 55 insertions(+) create mode 100644 src/hades2/mod_settings/mod_settings.cpp create mode 100644 src/hades2/mod_settings/mod_settings.hpp diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp new file mode 100644 index 0000000..876eb97 --- /dev/null +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -0,0 +1,45 @@ +#include "mod_settings.hpp" + +#include +#include +#include + +// clang-format off +#include +using namespace al; +// clang-format on +#undef ERROR + +namespace big::mod_settings +{ + // The in-game options menu is the native C++ screen sgg::MiscSettingsScreen. Its + // categories and per-category option widgets are built in the engine with no Lua + // entry point, so the "Mods" category has to be added by hooking the screen itself. + // The constructor builds the category buttons and their widgets, making it the point + // at which the extra category is injected. + // ctor(this, sgg::ScreenManager*, sgg::MenuScreen* opened_from, eastl::string& profile_name) + static void* hook_MiscSettingsScreen_ctor(void* self, void* screen_manager, void* opened_from, void* profile_name) + { + // The engine constructor returns `this`; forward it unchanged. + auto* screen = big::g_hooking->get_original()(self, screen_manager, opened_from, profile_name); + + // TODO: add the "Mods" category button and render its panel of mod settings. + + return screen; + } + + void register_hooks() + { + const auto ctor = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::MiscSettingsScreen"]; + if (!ctor) + { + LOG(WARNING) + << "sgg::MiscSettingsScreen::MiscSettingsScreen not found; the in-game Mods options tab is unavailable"; + return; + } + + static auto hook_ = hooking::detour_hook_helper::add_queue( + "sgg::MiscSettingsScreen::MiscSettingsScreen", + ctor); + } +} // namespace big::mod_settings diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp new file mode 100644 index 0000000..c235cfa --- /dev/null +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -0,0 +1,6 @@ +#pragma once + +namespace big::mod_settings +{ + void register_hooks(); +} // namespace big::mod_settings diff --git a/src/main.cpp b/src/main.cpp index f51f9c2..722ba27 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5,6 +5,7 @@ #include "gui/gui.hpp" #include "gui/renderer.hpp" #include "hades2/hooks.hpp" +#include "hades2/mod_settings/mod_settings.hpp" #include "hooks/hooking.hpp" #include "logger/exception_handler.hpp" #include "lua/lua_manager.hpp" @@ -2814,6 +2815,9 @@ extern "C" __declspec(dllexport) void my_main() } } + // Adds a "Mods" category to the in-game options + big::mod_settings::register_hooks(); + { static auto read_anim_data_ptr = big::hades2_symbol_to_address["sgg::GameDataManager::ReadAllAnimationData"]; if (read_anim_data_ptr) From 1ad0b7a012d5ccec8e7d1dd46a2c8a4fcc78a090 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:38:39 +0100 Subject: [PATCH 002/100] Add native in-game Mods options tab for editing mod .cfg settings --- src/hades2/mod_settings/mod_settings.cpp | 1144 +++++++++++++++++++++- src/hades2/mod_settings/sgg_gui.hpp | 148 +++ 2 files changed, 1279 insertions(+), 13 deletions(-) create mode 100644 src/hades2/mod_settings/sgg_gui.hpp diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 876eb97..7f8282e 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1,8 +1,18 @@ #include "mod_settings.hpp" +#include "sgg_gui.hpp" + +#include +#include +#include +#include #include #include +#include #include +#include +#include +#include // clang-format off #include @@ -12,34 +22,1142 @@ using namespace al; namespace big::mod_settings { - // The in-game options menu is the native C++ screen sgg::MiscSettingsScreen. Its - // categories and per-category option widgets are built in the engine with no Lua - // entry point, so the "Mods" category has to be added by hooking the screen itself. - // The constructor builds the category buttons and their widgets, making it the point - // at which the extra category is injected. - // ctor(this, sgg::ScreenManager*, sgg::MenuScreen* opened_from, eastl::string& profile_name) + using sgg::GUIComponent; + using sgg::MenuScreen; + using sgg::MiscSettingsScreen; + using sgg::Vec2; + + // Hades II's in-game options menu is the native C++ screen sgg::MiscSettingsScreen. + // Its category tabs include several non-user categories (Editor, Debug, ...) that are + // created but hidden; the "Editor" one is reused as the "Mods" tab. + // + // Option rows are native GUIComponentButtons built here. A freshly constructed + // component is invisible because it has no visual data; MenuScreen::ApplyDataToComponent + // applies the screen's SJSON template whose name matches the component's mName, which + // is what makes it render. So each row is named after an existing template + // ("CategoryOptionsButton"), has that template applied, is given its label, and is + // linked into mComponents (drawn) and mOptions (freed/unlinked on category switch). + + // GUIComponent::mName lives at this offset; it is an eastl::string used by + // ApplyDataToComponent to look up the matching template. + static constexpr std::size_t gui_component_name_offset = 0x4'88; + + // Each GUIComponent embeds an sgg::ComponentData (mData) whose mDef (sgg::ComponentDataDef) + // drives its visuals/layout. Retuning mDef then re-running ComponentData::SetupComponent + // re-applies the template - this is how a plain button is converted into a key-rebind + // style text row. Offsets validated against the Ship Hades2.pdb. + static constexpr std::size_t component_data_offset = 0x88; // GUIComponent::mData (sgg::ComponentData) + static constexpr std::size_t component_def_offset = 0xA8; // mData(0x88) + ComponentData::mDef(0x20) + + // Field offsets inside sgg::ComponentDataDef (relative to component_def_offset). + static constexpr std::size_t def_use_text_area = 0x05; // mUseTextArea (bool) + static constexpr std::size_t def_add_text_area = 0x06; // mAddTextArea (bool) + static constexpr std::size_t def_y = 0x20; // mY (float) row Y, read by UpdateScrollState + static constexpr std::size_t def_offset_y = 0x2C; // mOffsetY (float) template vertical offset + static constexpr std::size_t def_scale = 0x34; // mScale (float) uniform component scale + static constexpr std::size_t def_text_offset_x = 0x50; // mTextOffsetX (float) + static constexpr std::size_t def_width = 0x74; // mWidth (float -> mCustomWidth) + static constexpr std::size_t def_height = 0x78; // mHeight (float -> mCustomHeight) + static constexpr std::size_t def_graphic = 0x80; // mGraphic (HashGuid) + static constexpr std::size_t def_selected_graphic = 0x84; // mSelectedGraphic (HashGuid) + static constexpr std::size_t def_alternate_graphic = 0x88; // mAlternateGraphic (HashGuid) + static constexpr std::size_t def_add_color = 0x0D; // mAddColor (bool) + static constexpr std::size_t def_red = 0xEC; // mRed button tint (float) + static constexpr std::size_t def_green = 0xF0; // mGreen button tint (float) + static constexpr std::size_t def_blue = 0xF4; // mBlue button tint (float) + static constexpr std::size_t def_text_justification = 0xEA; // mTextJustification (sgg::Justification: LEFT=0) + static constexpr std::size_t def_text_red = 0x1'0C; // mTextRed (float) + static constexpr std::size_t def_text_green = 0x1'10; // mTextGreen (float) + static constexpr std::size_t def_text_blue = 0x1'14; // mTextBlue (float) + static constexpr std::size_t def_sel_text_red = 0x1'28; // mSelectedTextRed (float) + static constexpr std::size_t def_sel_text_green = 0x1'2C; // mSelectedTextGreen (float) + static constexpr std::size_t def_sel_text_blue = 0x1'30; // mSelectedTextBlue (float) + static constexpr std::size_t def_spacing = 0x1'5C; // mSpacing (float) row pitch, read by UpdateScrollState + + using ctor_fn = void* (*)(void* button, void* owner_screen); + using push_back_fn = void (*)(void* vector, GUIComponent** value); + using apply_data_fn = void (*)(void* menu_screen, GUIComponent* component); + using set_label_fn = void (*)(void* button, const char* text); + using update_scroll_fn = void (*)(void* misc_settings_screen); + using set_animation_fn = void (*)(void* button, std::uint32_t graphic_id); + using setup_component_fn = void (*)(void* component, void* component_data); + using set_texture_fn = void (*)(void* button, std::uint32_t graphic_id, bool reset); + using set_sel_texture_fn = void (*)(void* button, std::uint32_t graphic_id); + using disable_fn = void (*)(void* button); + using was_key_pressed_fn = bool (*)(void* input_handler, int keyboard_button_id); + using dtor_fn = void (*)(void* button); + + // sgg::HashGuid is a 32-bit interned-string id in its first field. + struct HashGuid + { + std::uint32_t m_id; + }; + + using hash_lookup_fn = HashGuid* (*)(HashGuid * out, const char* str, std::size_t len); + + static ctor_fn g_button_ctor = nullptr; + static push_back_fn g_push_back = nullptr; + static apply_data_fn g_apply_data = nullptr; + static set_label_fn g_set_label = nullptr; + static update_scroll_fn g_update_scroll = nullptr; + static set_animation_fn g_set_animation = nullptr; + static hash_lookup_fn g_hash_lookup = nullptr; + static setup_component_fn g_setup_component = nullptr; + static set_texture_fn g_set_normal_texture = nullptr; + static set_sel_texture_fn g_set_selected_texture = nullptr; + static dtor_fn g_button_dtor = nullptr; + static disable_fn g_disable = nullptr; + static was_key_pressed_fn g_was_key_pressed = nullptr; + + // sgg::KeyboardButtonId values used for edit confirm/cancel (validated in the PDB). + static constexpr int key_escape = 0; + static constexpr int key_kp_enter = 113; + static constexpr int key_return = 127; + + // Hash of the game's "Blank" (empty) graphic, resolved once, used to hide a row's + // button background so it renders as a plain text label. + static std::uint32_t g_blank_graphic = 0; + + // Panel layout, in native 1080p menu coordinates. The engine's UpdateScrollState pass + // positions each on-page row at Y = (index - pageStart) * row_pitch + row_base_y + + // ScreenCenterOffsetY, and X = the row's own location. Rows mirror the key-rebind + // ControlButton layout: the component is anchored to the right pane and its text is + // left-justified via a negative text offset, matching the native option-name column. + static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton + static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column + static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) + static constexpr float row_base_y = 315.0f; // first row's Y (aligns with the tab column) + static constexpr float row_pitch = 54.0f; // vertical distance between rows + static constexpr std::uint32_t rows_per_page = 8; + + // What a panel row represents, so a click can be routed to the right action. + enum class RowKind + { + mod_entry, // opens that mod's settings + back, // returns to the mod list + setting, // edits one config entry + action, // a button that runs an action (e.g. Apply/Reset) + }; + + struct PanelRow + { + GUIComponent* component = nullptr; + RowKind kind = RowKind::mod_entry; + std::string stem; // owning mod's config-file stem + std::string setting_key; // config entry key (setting rows only) + + // The bound config entry (setting rows only); valid for the config file's lifetime, + // which spans the whole menu session. + toml_v2::config_file::config_entry_base* entry = nullptr; + + bool disabled = false; // greyed & non-interactable (mod disabled) + bool is_enabled_toggle = false; // the mod's master "enabled" toggle + }; + + static std::vector g_rows; + + // Which view the Mods panel is currently showing, plus a deferred navigation request + // that a click sets and the Update hook applies at a safe point (outside input/click + // iteration, where mutating the component vectors is safe). + enum class View + { + mod_list, + mod_settings, + }; + + static View g_view = View::mod_list; + static std::string g_view_stem; // mod whose settings are shown (mod_settings view) + static bool g_nav_pending = false; + static View g_pending_view = View::mod_list; + static std::string g_pending_stem; + + // Freetext edit state (number/string settings). A click enters edit mode; typed input + // is captured in the window procedure and applied on the game thread in the Update hook. + static bool g_editing = false; + static GUIComponent* g_edit_component = nullptr; + static toml_v2::config_file::config_entry_base* g_edit_entry = nullptr; + static std::string g_edit_key; + static std::string g_edit_buffer; + static bool g_edit_numeric = false; // restrict input to a numeric literal + static bool g_edit_confirm = false; + static bool g_edit_cancel = false; + + // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the + // author (up to the first '-') and shows the mod name with '_' replaced by spaces. + // "SGG_Modding-Chalk" -> "Chalk"; "NikkelM-Zagreus_Journey" -> "Zagreus Journey". + static std::string display_name_from_stem(const std::string& stem) + { + const auto dash = stem.find('-'); + std::string name = (dash == std::string::npos) ? stem : stem.substr(dash + 1); + std::replace(name.begin(), name.end(), '_', ' '); + return name; + } + + static GUIComponent* mods_category_button(MiscSettingsScreen* screen) + { + return reinterpret_cast(screen->m_editor_options_button); + } + + static void show_mods_tab(MiscSettingsScreen* screen) + { + auto* button = mods_category_button(screen); + if (!button) + { + return; + } + + button->m_hidden = false; + button->m_is_useable = true; + + if (g_set_label) + { + g_set_label(button, "Mods"); + } + } + + // Writes an in-place EASTL short-string (SSO, up to 22 chars) into a component field. + static void set_sso_string(void* field, const char* text) + { + char* bytes = static_cast(field); + std::size_t n = std::strlen(text); + if (n > 22) + { + n = 22; + } + std::memset(bytes, 0, 24); + std::memcpy(bytes, text, n); + bytes[0x17] = static_cast(0x17 - n); // SSO: remaining = capacity(23) - length + } + + static GUIComponent* create_button(MiscSettingsScreen* screen) + { + if (!g_button_ctor || !g_push_back || !g_apply_data) + { + return nullptr; + } + + auto* row = static_cast(_aligned_malloc(sgg::gui_component_button_size, 8)); + if (!row) + { + return nullptr; + } + + g_button_ctor(row, screen); + *reinterpret_cast(reinterpret_cast(row) + sgg::gui_component_button_owner_offset) = screen; + return row; + } + + // Links a finished row into the drawn/hit-tested (mComponents) and paged (mOptions) + // vectors, sets its X, and starts it transparent. UpdateScrollState only fades in and + // repositions on-page rows, so off-page rows must start invisible to avoid flashing + // stacked at the top. + static void finalize_row(MiscSettingsScreen* screen, GUIComponent* row) + { + GUIComponent* value = row; + auto* menu = reinterpret_cast(screen); + g_push_back(&menu->m_components, &value); + g_push_back(&screen->m_options, &value); + + row->m_location_x = row_location_x; + row->m_fade_opacity = 0.0f; + } + + // Shows the on or off toggle graphic for a toggle row. The OptionToggleButton template + // stores both graphic hashes in the row's def (mGraphic = on, mAlternateGraphic = off); + // pick one and set it as the drawn texture. + static void set_toggle_graphic(GUIComponent* row, bool is_on) + { + if (!g_set_normal_texture) + { + return; + } + char* def = reinterpret_cast(row) + component_def_offset; + const std::uint32_t on_hash = *reinterpret_cast(def + def_graphic); + const std::uint32_t off_hash = *reinterpret_cast(def + def_alternate_graphic); + g_set_normal_texture(row, is_on ? on_hash : off_hash, false); + } + + // Dims a row's def text colours (both normal and selected) so a disabled row reads as + // greyed out and does not recolour on hover. Must be applied before SetupComponent so + // the change reaches the text box. + static void set_def_text_grey(GUIComponent* row) + { + char* def = reinterpret_cast(row) + component_def_offset; + constexpr float grey = 0.22f; + *reinterpret_cast(def + def_text_red) = grey; + *reinterpret_cast(def + def_text_green) = grey; + *reinterpret_cast(def + def_text_blue) = grey; + *reinterpret_cast(def + def_sel_text_red) = grey; + *reinterpret_cast(def + def_sel_text_green) = grey; + *reinterpret_cast(def + def_sel_text_blue) = grey; + } + + // A plain left-justified text row (mod names, Back, and non-toggle settings). Applies a + // template for a valid font/colours, then retunes the row's own def into the key-rebind + // "ControlButton" style - no background graphic, left text, and a text-area hit region + // that hugs the label - and clears any leftover textures. Disabled rows are greyed and + // made non-interactable. + static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false) + { + auto* row = create_button(screen); + if (!row) + { + return nullptr; + } + auto* row_bytes = reinterpret_cast(row); + + set_sso_string(row_bytes + gui_component_name_offset, "CategoryOptionsButton"); + g_apply_data(reinterpret_cast(screen), row); + + char* def = row_bytes + component_def_offset; + *reinterpret_cast(def + def_add_text_area) = 1; // hit area follows the text + *reinterpret_cast(def + def_use_text_area) = 0; // (union with the empty graphic area) + *reinterpret_cast(def + def_graphic) = 0; // no button background + *reinterpret_cast(def + def_selected_graphic) = 0; // no highlight box (text recolours instead) + *reinterpret_cast(def + def_alternate_graphic) = 0; + *reinterpret_cast(def + def_width) = 0.0f; // let the text drive the area + *reinterpret_cast(def + def_height) = 0.0f; + *reinterpret_cast(def + def_text_justification) = 0; // sgg::Justification::LEFT + *reinterpret_cast(def + def_text_offset_x) = row_text_offset_x; + *reinterpret_cast(def + def_y) = row_base_y; // read by UpdateScrollState + *reinterpret_cast(def + def_spacing) = row_pitch; // read by UpdateScrollState + + if (disabled) + { + set_def_text_grey(row); + } + + if (g_setup_component) + { + g_setup_component(row, row_bytes + component_data_offset); + } + + // SetupComponent applies our zeroed graphic fields but does not actively tear down + // the normal/selected textures a prior template already set. Clear them explicitly. + if (g_set_normal_texture) + { + g_set_normal_texture(row, 0, false); + } + if (g_set_selected_texture) + { + g_set_selected_texture(row, 0); + } + if (g_set_animation && g_blank_graphic) + { + g_set_animation(row, g_blank_graphic); + } + + if (g_set_label) + { + g_set_label(row, label); + } + + if (disabled && g_disable) + { + g_disable(row); + } + + finalize_row(screen, row); + return row; + } + + // A toggle row (boolean setting): a left-justified label plus the native on/off toggle + // switch graphic on the right. The OptionToggleButton template already supplies the + // toggle graphic, left-justified text and text area; we only realign it to our row grid + // (mY/mSpacing, read directly by UpdateScrollState) and choose the on/off graphic. + // Disabled rows are greyed and made non-interactable. + static GUIComponent* make_toggle_row(MiscSettingsScreen* screen, const char* label, bool is_on, bool disabled = false) + { + auto* row = create_button(screen); + if (!row) + { + return nullptr; + } + auto* row_bytes = reinterpret_cast(row); + + set_sso_string(row_bytes + gui_component_name_offset, "OptionToggleButton"); + g_apply_data(reinterpret_cast(screen), row); + + char* def = row_bytes + component_def_offset; + *reinterpret_cast(def + def_y) = row_base_y; + *reinterpret_cast(def + def_spacing) = row_pitch; + + // Greying needs a SetupComponent pass to reach the text box and button colour; the + // toggle graphic is re-chosen afterwards so the pass does not revert it. The button + // tint is switched from additive to a multiplicative dim so the toggle graphic reads + // as greyed rather than full brightness. + if (disabled) + { + set_def_text_grey(row); + *reinterpret_cast(def + def_add_color) = 0; + *reinterpret_cast(def + def_red) = 0.4f; + *reinterpret_cast(def + def_green) = 0.4f; + *reinterpret_cast(def + def_blue) = 0.4f; + if (g_setup_component) + { + g_setup_component(row, row_bytes + component_data_offset); + } + } + + if (g_set_label) + { + g_set_label(row, label); + } + + set_toggle_graphic(row, is_on); + + if (disabled && g_disable) + { + g_disable(row); + } + + finalize_row(screen, row); + return row; + } + + // A centered native button row (for actions like Apply/Reset), using the + // CategoryOptionsButton template unchanged so it keeps its Button_Secondary box graphic + // and centered label - visually distinct from the plain-text setting rows. Only the row + // grid position (mY/mSpacing) is overridden. + static GUIComponent* make_button_row(MiscSettingsScreen* screen, const char* label, bool disabled = false) + { + auto* row = create_button(screen); + if (!row) + { + return nullptr; + } + auto* row_bytes = reinterpret_cast(row); + + set_sso_string(row_bytes + gui_component_name_offset, "CategoryOptionsButton"); + g_apply_data(reinterpret_cast(screen), row); + + char* def = row_bytes + component_def_offset; + *reinterpret_cast(def + def_y) = row_base_y; + *reinterpret_cast(def + def_spacing) = row_pitch; + *reinterpret_cast(def + def_offset_y) = 0.0f; // drop the template's built-in vertical offset + *reinterpret_cast(def + def_scale) = 0.85f; // shrink slightly for top/bottom breathing room + // Size the hit-test rect to cover the whole visible (scaled) button; the template's + // 280x40 was smaller than the Button_Secondary graphic, which cut off hover top/bottom. + *reinterpret_cast(def + def_width) = 340.0f; + *reinterpret_cast(def + def_height) = 58.0f; + + if (disabled) + { + set_def_text_grey(row); + } + + if (g_setup_component) + { + g_setup_component(row, row_bytes + component_data_offset); + } + + if (g_set_label) + { + g_set_label(row, label); + } + + if (disabled && g_disable) + { + g_disable(row); + } + + finalize_row(screen, row); + + // Centre the button in the content pane (finalize_row anchors rows at the right-hand + // option column, which would put the button over the scrollbar). + row->m_location_x = button_center_x; + return row; + } + + // Removes the first pointer equal to `value` from an eastl vector by shifting the tail + // down in place - the same unlink the engine's DoShowCategory performs. No-op if not + // present; the backing storage is left owned by the vector. + static void vector_erase(sgg::eastl_vector& vec, GUIComponent* value) + { + for (GUIComponent** it = vec.m_begin; it != vec.m_end; ++it) + { + if (*it == value) + { + std::memmove(it, it + 1, reinterpret_cast(vec.m_end) - reinterpret_cast(it + 1)); + --vec.m_end; + return; + } + } + } + + // Tears down every custom row we currently own: clears any screen pointer that still + // references a row (so the engine cannot dereference it after free), unlinks it from + // the drawn/hit-tested mComponents and the paged mOptions, then destroys and frees it. + // Our rows are not registered in the reflection helper, so the engine never frees them + // and never double-frees here. Safe to call when g_rows is empty or already unlinked. + static void destroy_rows(MiscSettingsScreen* screen) + { + auto* menu = reinterpret_cast(screen); + + for (const auto& row : g_rows) + { + GUIComponent* comp = row.component; + if (!comp) + { + continue; + } + + if (menu->m_mouse_over_component == comp) + { + menu->m_mouse_over_component = nullptr; + } + if (menu->m_selected_component == comp) + { + menu->m_selected_component = nullptr; + } + if (screen->m_component_focused == comp) + { + screen->m_component_focused = nullptr; + } + if (screen->m_last_option_button == comp) + { + screen->m_last_option_button = nullptr; + } + + vector_erase(menu->m_components, comp); + vector_erase(screen->m_options, comp); + + if (g_button_dtor) + { + g_button_dtor(comp); + } + _aligned_free(comp); + } + + g_rows.clear(); + } + + // Level 1: one row per installed mod (config-file stem), friendly display name, sorted. + static void build_mod_list(MiscSettingsScreen* screen) + { + std::vector stems; + for (const auto* cfg : toml_v2::config_file::g_config_files) + { + if (!cfg || cfg->m_config_file_stem_as_str.empty()) + { + continue; + } + if (std::find(stems.begin(), stems.end(), cfg->m_config_file_stem_as_str) == stems.end()) + { + stems.push_back(cfg->m_config_file_stem_as_str); + } + } + + std::vector> mods; // (display name, stem) + mods.reserve(stems.size()); + for (const auto& stem : stems) + { + mods.emplace_back(display_name_from_stem(stem), stem); + } + std::sort(mods.begin(), + mods.end(), + [](const auto& a, const auto& b) + { + return a.first < b.first; + }); + + for (const auto& [display, stem] : mods) + { + if (auto* row = make_text_row(screen, display.c_str())) + { + g_rows.push_back({row, RowKind::mod_entry, stem, {}}); + } + } + } + + // Setting key as a display string (underscores become spaces). + static std::string key_to_display(const std::string& key) + { + std::string display = key; + std::replace(display.begin(), display.end(), '_', ' '); + return display; + } + + // "Key : value" label for a non-toggle setting row. + static std::string setting_label(const std::string& key, toml_v2::config_file::config_entry_base* entry) + { + return key_to_display(key) + " : " + (entry ? entry->get_serialized_value() : std::string{}); + } + + // Accepts a character into a numeric edit buffer only if the result stays a plausible + // numeric literal: an optional leading sign, digits, at most one decimal point. + static bool numeric_char_ok(const std::string& buffer, char c) + { + if (c >= '0' && c <= '9') + { + return true; + } + if (c == '-' || c == '+') + { + return buffer.empty(); // sign only as the first character + } + if (c == '.') + { + return buffer.find('.') == std::string::npos; // a single decimal point + } + return false; + } + + // Window-procedure callback: while a freetext setting is being edited, capture typed + // characters into the edit buffer. Runs on the game's message-pump thread (same thread + // as Update). Printable characters arrive via WM_CHAR; Backspace via WM_KEYDOWN; a mouse + // click anywhere commits the edit (Enter/Escape are read from the game input in the + // HandleInput hook, which also blocks the menu from reacting). + static void on_wndproc(HWND, UINT msg, WPARAM wparam, LPARAM) + { + if (!g_editing) + { + return; + } + + if (msg == WM_LBUTTONDOWN || msg == WM_RBUTTONDOWN) + { + // Clicking away from the edited row submits the current value, like Enter. + g_edit_confirm = true; + return; + } + + if (msg == WM_KEYDOWN) + { + // Only editing keys (Backspace) are handled here; Enter/Escape come from HandleInput. + if (wparam == VK_BACK && !g_edit_buffer.empty()) + { + g_edit_buffer.pop_back(); + } + return; + } + + if (msg == WM_CHAR) + { + const unsigned c = static_cast(wparam); + if (c < 32 || c >= 127) // control chars handled via WM_KEYDOWN + { + return; + } + const char ch = static_cast(c); + if (g_edit_numeric && !numeric_char_ok(g_edit_buffer, ch)) + { + return; + } + g_edit_buffer.push_back(ch); + } + } + + // Registers on_wndproc with the framework's window hook the first time it is needed. + static void ensure_wndproc_registered() + { + static bool registered = false; + if (registered || !g_renderer) + { + return; + } + g_renderer->add_wndproc_callback( + [](HWND hwnd, UINT32 msg, WPARAM wparam, LPARAM lparam) + { + on_wndproc(hwnd, msg, wparam, lparam); + }); + registered = true; + } + + static void enter_edit_mode(GUIComponent* component, toml_v2::config_file::config_entry_base* entry, const std::string& key) + { + ensure_wndproc_registered(); + g_editing = true; + g_edit_component = component; + g_edit_entry = entry; + g_edit_key = key; + g_edit_buffer = entry ? entry->get_serialized_value() : std::string{}; + g_edit_numeric = entry && entry->type() != typeid(std::string); + g_edit_confirm = false; + g_edit_cancel = false; + } + + static void exit_edit_mode() + { + g_editing = false; + g_edit_component = nullptr; + g_edit_entry = nullptr; + g_edit_confirm = false; + g_edit_cancel = false; + } + + // Requests an in-place rebuild of the current settings view (to reflect a committed or + // reverted edit) on the next Update. + static void request_settings_rebuild() + { + g_pending_view = g_view; + g_pending_stem = g_view_stem; + g_nav_pending = true; + } + + // Commits or cancels a pending edit. Called from the HandleInput hook so it runs on the + // same frame the triggering key/click is swallowed (HandleInput returns true that + // frame), which prevents a submitting mouse click from also activating the row it lands + // on. Returns true if the edit ended this call. + static bool commit_or_cancel_edit() + { + if (g_edit_confirm) + { + if (g_edit_entry) + { + // set_serialized_value validates (e.g. numbers) and only stores/saves a + // valid value, so bad input for a number simply keeps the old value. + g_edit_entry->set_serialized_value(g_edit_buffer); + } + exit_edit_mode(); + request_settings_rebuild(); + return true; + } + if (g_edit_cancel) + { + exit_edit_mode(); + request_settings_rebuild(); + return true; + } + return false; + } + + // Live-updates the edited row's label with a trailing cursor. Called from Update while + // editing is still active. + static void update_edit_label() + { + if (g_edit_component && g_set_label) + { + const std::string label = key_to_display(g_edit_key) + " : " + g_edit_buffer + "|"; + g_set_label(g_edit_component, label.c_str()); + } + } + + // True if `key` is the mod's master enable switch ("enabled", any case). + static bool is_enabled_key(const std::string& key) + { + return big::string::to_lower(key) == "enabled"; + } + + // Level 2: a Back row followed by one row per config entry belonging to `stem`. Boolean + // entries render as native toggle rows; other types render as "key : value" text rows. + // A boolean "enabled" entry (if present) is pinned to the top; when it is off, every + // other setting is greyed out and made non-interactable. + static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem) + { + if (auto* row = make_text_row(screen, "< Back")) + { + g_rows.push_back({row, RowKind::back, stem, {}}); + } + + // Gather this mod's entries, keeping map order, and locate the master "enabled" one. + std::vector> entries; // (key, entry) + toml_v2::config_file::config_entry_base* enabled_entry = nullptr; + for (auto* cfg : toml_v2::config_file::g_config_files) + { + if (!cfg || cfg->m_config_file_stem_as_str != stem) + { + continue; + } + for (auto& [key, entry] : cfg->m_entries) + { + if (!entry) + { + continue; + } + entries.emplace_back(key.m_key, entry.get()); + if (!enabled_entry && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) + { + enabled_entry = entry.get(); + } + } + } + + // Pin the enabled entry to the top; the rest keep their order. + std::stable_sort(entries.begin(), + entries.end(), + [&](const auto& a, const auto& b) + { + return (a.second == enabled_entry) && (b.second != enabled_entry); + }); + + const bool mod_enabled = !enabled_entry || enabled_entry->get_value_base(); + + for (const auto& [key, entry] : entries) + { + const bool is_enabled_row = (entry == enabled_entry); + const bool disabled = !is_enabled_row && !mod_enabled; + + GUIComponent* row = nullptr; + if (entry->type() == typeid(bool)) + { + row = make_toggle_row(screen, key_to_display(key).c_str(), entry->get_value_base(), disabled); + } + else + { + row = make_text_row(screen, setting_label(key, entry).c_str(), disabled); + } + + if (row) + { + PanelRow pr{row, RowKind::setting, stem, key, entry}; + pr.disabled = disabled; + pr.is_enabled_toggle = is_enabled_row; + g_rows.push_back(pr); + } + } + } + + static void build_panel(MiscSettingsScreen* screen, bool instant = false) + { + // Preserve the current scroll offset across an in-place refresh (same view/mod, e.g. + // after committing a setting edit or toggling "enabled") so confirming a setting on a + // lower page does not jump back to the top. A real view change (instant == false) + // starts at the top. + const std::uint32_t prev_start = screen->m_page_start_index; + + // Remove any rows from a previous view/visit before building the new set. + destroy_rows(screen); + + // Resolve the blank graphic lazily: the string-intern table is not ready at hook + // registration time, so "Blank" only hashes correctly once the game is running. + if (!g_blank_graphic && g_hash_lookup) + { + HashGuid res{}; + g_hash_lookup(&res, "Blank", 5); + g_blank_graphic = res.m_id; + } + + if (g_view == View::mod_settings && !g_view_stem.empty()) + { + build_mod_settings(screen, g_view_stem); + } + else + { + build_mod_list(screen); + } + + // Let the engine position, paginate and drive the scrollbar/arrows for the rows. + std::uint32_t start = 0; + if (instant) + { + // Clamp the preserved offset in case the row count shrank (e.g. a row became + // hidden), keeping a full page in view where possible. + const std::uint32_t row_count = static_cast(g_rows.size()); + const std::uint32_t max_start = row_count > rows_per_page ? row_count - rows_per_page : 0; + start = prev_start > max_start ? max_start : prev_start; + } + screen->m_page_start_index = start; + screen->m_options_per_page = rows_per_page; + if (g_update_scroll) + { + g_update_scroll(screen); + } + + // For an in-place refresh (e.g. toggling the mod's "enabled" switch, which only + // changes greying) snap each row straight to its final visibility so the panel does + // not flash a fade-out/in. UpdateScrollState set the on-page rows' fade target to 1 + // and off-page rows' to 0, so copying target->opacity gives the settled look at once. + if (instant) + { + for (const auto& row : g_rows) + { + if (row.component) + { + row.component->m_fade_opacity = row.component->m_fade_target; + } + } + } + } + + // Applies a queued navigation (mod list <-> a mod's settings) by rebuilding the panel. + // Called from the Update hook, i.e. outside click/input iteration, where mutating the + // component vectors is safe. A rebuild that stays on the same view/mod (e.g. after + // toggling "enabled") is applied instantly to avoid a fade flash; a real view change + // keeps the fade-in. + static void apply_nav(MiscSettingsScreen* screen) + { + const bool instant = (g_pending_view == g_view) && (g_pending_stem == g_view_stem); + + g_view = g_pending_view; + g_view_stem = g_pending_stem; + build_panel(screen, instant); + } + static void* hook_MiscSettingsScreen_ctor(void* self, void* screen_manager, void* opened_from, void* profile_name) { + // Reset state BEFORE running the original ctor: the original ctor immediately shows + // the last-viewed category, and if that is the Mods tab it builds our panel via + // DoShowCategory. Clearing g_rows after the original would wipe those fresh rows. + g_rows.clear(); + g_view = View::mod_list; + g_view_stem.clear(); + g_nav_pending = false; + exit_edit_mode(); + // The engine constructor returns `this`; forward it unchanged. - auto* screen = big::g_hooking->get_original()(self, screen_manager, opened_from, profile_name); + auto* screen = static_cast(big::g_hooking->get_original()(self, screen_manager, opened_from, profile_name)); - // TODO: add the "Mods" category button and render its panel of mod settings. + if (!mods_category_button(screen)) + { + LOG(WARNING) << "[mod_settings] no reusable category button; Mods tab not installed"; + return screen; + } + show_mods_tab(screen); return screen; } + static void* hook_MiscSettingsScreen_DoShowCategory(void* self, void* category_button, std::uint32_t category_flag) + { + auto* screen = static_cast(self); + const bool is_mods_tab = category_button && category_button == reinterpret_cast(screen->m_editor_options_button); + + auto* result = big::g_hooking->get_original()(self, category_button, category_flag); + + show_mods_tab(screen); + + if (is_mods_tab) + { + // Entering the tab always starts at the mod list; drill-down happens in-place + // via the Update hook, not by re-entering the category. + g_view = View::mod_list; + g_view_stem.clear(); + g_nav_pending = false; + exit_edit_mode(); + build_panel(screen); + } + + return result; + } + + // Button-click hook. GUIComponentButton overrides GUIComponent::OnClicked (vtable slot + // +0x100, the engine's terminal-click), so this is where our button rows' clicks land. + // For our rows the engine returns false (they have no bound activate function) but still + // plays the press sound, so we must match the row regardless of the return value. The + // actual panel rebuild is deferred to the Update hook, where mutating the component + // vectors is safe (this runs mid input iteration). + static bool hook_GUIComponentButton_OnClicked(GUIComponent* self, std::uint64_t location) + { + RowKind kind = RowKind::mod_entry; + std::string stem; + std::string setting_key; + toml_v2::config_file::config_entry_base* entry = nullptr; + bool matched = false; + bool disabled = false; + bool is_enabled_toggle = false; + + if (self) + { + for (const auto& row : g_rows) + { + if (row.component == self) + { + kind = row.kind; + stem = row.stem; + setting_key = row.setting_key; + entry = row.entry; + disabled = row.disabled; + is_enabled_toggle = row.is_enabled_toggle; + matched = true; + break; + } + } + } + + const bool result = big::g_hooking->get_original()(self, location); + + if (matched && !disabled) + { + switch (kind) + { + case RowKind::mod_entry: + g_pending_view = View::mod_settings; + g_pending_stem = stem; + g_nav_pending = true; + break; + case RowKind::back: + g_pending_view = View::mod_list; + g_pending_stem.clear(); + g_nav_pending = true; + break; + case RowKind::setting: + // Boolean settings toggle in place; other types open a freetext editor. + if (entry && entry->type() == typeid(bool)) + { + const bool new_value = !entry->get_value_base(); + entry->set_value_base(new_value); + set_toggle_graphic(self, new_value); + + // Toggling the mod's master "enabled" switch changes which other rows + // are greyed out, so rebuild the settings view on the next Update. + if (is_enabled_toggle) + { + g_pending_view = View::mod_settings; + g_pending_stem = stem; + g_nav_pending = true; + } + } + else if (entry) + { + enter_edit_mode(self, entry, setting_key); + } + break; + case RowKind::action: + // TODO: dispatch the action row's callback. + break; + } + } + + return result; + } + + // Per-frame screen update: RCX=this, XMM1=dt (float), R8=input. We apply any queued + // navigation here because the click/input iteration has fully unwound by now, so + // tearing down and rebuilding the component vectors is safe. We rebuild before the + // original runs so this frame lays out and hover-resolves the new rows. + static void* hook_MiscSettingsScreen_Update(void* self, float dt, void* input) + { + auto* screen = static_cast(self); + const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); + + // Freetext editing: refresh the edited row's live label. Confirm/cancel is handled in + // the HandleInput hook so the submitting key/click is swallowed on the same frame. + if (g_editing) + { + if (on_mods_tab) + { + update_edit_label(); + } + else + { + exit_edit_mode(); // safety: never stay in edit mode off the Mods tab + } + } + + if (g_nav_pending) + { + // Only act while this screen is actually showing the Mods tab. + if (on_mods_tab) + { + apply_nav(screen); + } + g_nav_pending = false; + } + + return big::g_hooking->get_original()(self, dt, input); + } + + // While a freetext setting is being edited, read Enter (confirm) and Escape (cancel) + // from the game's own per-frame input, commit/cancel here, then swallow the screen's + // input handling entirely so menu navigation and the Escape-to-close do not react. + // Committing here (rather than in Update) is important: HandleInput returns true this + // frame, so a submitting mouse click is swallowed and cannot also activate the row it + // lands on. Returning true without calling the original bypasses the whole close chain + // (the base MenuScreen::HandleInput is only reached via this function's tail-call). + static bool hook_MiscSettingsScreen_HandleInput(void* self, void* input, float x) + { + if (g_editing) + { + if (g_was_key_pressed && input) + { + if (g_was_key_pressed(input, key_return) || g_was_key_pressed(input, key_kp_enter)) + { + g_edit_confirm = true; + } + if (g_was_key_pressed(input, key_escape)) + { + g_edit_cancel = true; + } + } + commit_or_cancel_edit(); + return true; + } + return big::g_hooking->get_original()(self, input, x); + } + void register_hooks() { - const auto ctor = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::MiscSettingsScreen"]; - if (!ctor) + const auto ctor = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::MiscSettingsScreen"]; + const auto do_show_category = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::DoShowCategory"]; + + if (!ctor || !do_show_category) { - LOG(WARNING) - << "sgg::MiscSettingsScreen::MiscSettingsScreen not found; the in-game Mods options tab is unavailable"; + LOG(WARNING) << "[mod_settings] MiscSettingsScreen symbols not found; Mods options tab unavailable"; return; } - static auto hook_ = hooking::detour_hook_helper::add_queue( + g_set_label = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetDisplayName"].as_func(); + g_button_ctor = big::hades2_symbol_to_address["sgg::GUIComponentButton::GUIComponentButton"].as_func(); + g_apply_data = big::hades2_symbol_to_address["sgg::MenuScreen::ApplyDataToComponent"].as_func(); + g_update_scroll = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::UpdateScrollState"].as_func(); + g_set_animation = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetAnimation"].as_func(); + g_hash_lookup = big::hades2_symbol_to_address["sgg::HashGuid::Lookup"].as_func(); + g_setup_component = big::hades2_symbol_to_address["sgg::ComponentData::SetupComponent"].as_func(); + g_set_normal_texture = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetNormalTexture"].as_func(); + g_set_selected_texture = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetSelectedTexture"].as_func(); + g_button_dtor = big::hades2_symbol_to_address["sgg::GUIComponentButton::~GUIComponentButton"].as_func(); + g_disable = big::hades2_symbol_to_address["sgg::GUIComponentButton::Disable"].as_func(); + g_was_key_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasKeyPressed"].as_func(); + g_push_back = + big::hades2_symbol_to_address["eastl::vector::push_back"].as_func(); + + if (!g_push_back) + { + const auto anchor = big::hades2_symbol_to_address["sgg::GUIComponentButton::GUIComponentButton"]; + if (anchor) + { + g_push_back = reinterpret_cast(anchor.as() - 0x11'5c'70 + 0x14'1e'd0); + } + } + + if (!g_button_ctor || !g_push_back || !g_apply_data || !g_set_label || !g_setup_component) + { + LOG(WARNING) << "[mod_settings] engine row helpers unresolved (ctor=" << (g_button_ctor != nullptr) << " push_back=" << (g_push_back != nullptr) << " apply=" << (g_apply_data != nullptr) << " label=" << (g_set_label != nullptr) << " setup=" << (g_setup_component != nullptr) << ")"; + } + + static auto ctor_hook = hooking::detour_hook_helper::add_queue( "sgg::MiscSettingsScreen::MiscSettingsScreen", ctor); + static auto category_hook = hooking::detour_hook_helper::add_queue( + "sgg::MiscSettingsScreen::DoShowCategory", + do_show_category); + + const auto on_clicked = big::hades2_symbol_to_address["sgg::GUIComponentButton::OnClicked"]; + if (on_clicked) + { + static auto onclick_hook = hooking::detour_hook_helper::add_queue( + "sgg::GUIComponentButton::OnClicked", + on_clicked); + } + else + { + LOG(WARNING) + << "[mod_settings] sgg::GUIComponentButton::OnClicked not found; mod rows will not be clickable"; + } + + const auto update = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::Update"]; + if (update) + { + static auto update_hook = hooking::detour_hook_helper::add_queue( + "sgg::MiscSettingsScreen::Update", + update); + } + else + { + LOG(WARNING) + << "[mod_settings] sgg::MiscSettingsScreen::Update not found; mod-row navigation is unavailable"; + } + + const auto handle_input = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::HandleInput"]; + if (handle_input) + { + static auto handle_input_hook = hooking::detour_hook_helper::add_queue("sgg::MiscSettingsScreen::HandleInput", handle_input); + } + else + { + LOG(WARNING) << "[mod_settings] sgg::MiscSettingsScreen::HandleInput not found; freetext editing may not " + "block menu nav"; + } } } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp new file mode 100644 index 0000000..0615568 --- /dev/null +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include +#include + +// Minimal views over the native Hades II option-screen GUI objects, limited to the +// fields this feature reads or writes. Offsets are validated with static_assert against +// the current game build; the matching engine functions are resolved by PDB symbol name +// at runtime (see big::hades2_symbol_to_address). Only sgg::GUIComponent base fields and +// MiscSettingsScreen members are used, which stay stable across the button-layout changes +// that occur between game versions. +namespace big::mod_settings::sgg +{ + // sgg::Vectormath Vector2: two floats, 8 bytes. As a function argument this is an + // integer-class aggregate, so it is passed in a general-purpose register (RDX/R8/...), + // not an XMM register - the by-value POD typing below reproduces that ABI. + struct Vec2 + { + float x; + float y; + }; + + static_assert(sizeof(Vec2) == 8); + + // eastl::vector stores three pointers (begin, end, capacity) followed by its + // allocator; begin/end are enough to iterate an existing vector. + template + struct eastl_vector + { + T* m_begin; + T* m_end; + T* m_capacity; + + T* begin() const + { + return m_begin; + } + + T* end() const + { + return m_end; + } + + std::size_t size() const + { + return static_cast(m_end - m_begin); + } + }; + + static_assert(sizeof(eastl_vector) == 0x18); + + struct GUIComponentButton; + + // sgg::GUIComponent, the base of every menu widget. + struct GUIComponent + { + char m_pad0[0x0C]; + bool m_hidden; // +0x0C + bool m_useable; // +0x0D + char m_pad1[0x02]; + float m_location_x; // +0x10 + float m_location_y; // +0x14 + char m_pad2[0x0F]; + bool m_is_useable; // +0x27 + bool m_can_be_focused; // +0x28 + char m_pad3[0x13]; + float m_fade_opacity; // +0x3C + char m_pad4[0x04]; + float m_fade_target; // +0x44 + std::int32_t m_custom_width; // +0x48 + std::int32_t m_custom_height; // +0x4C + char m_pad5[0x4'E8]; + std::uint64_t m_id; // +0x538 + }; + + static_assert(offsetof(GUIComponent, m_hidden) == 0x0C); + static_assert(offsetof(GUIComponent, m_useable) == 0x0D); + static_assert(offsetof(GUIComponent, m_location_x) == 0x10); + static_assert(offsetof(GUIComponent, m_is_useable) == 0x27); + static_assert(offsetof(GUIComponent, m_can_be_focused) == 0x28); + static_assert(offsetof(GUIComponent, m_fade_opacity) == 0x3C); + static_assert(offsetof(GUIComponent, m_fade_target) == 0x44); + static_assert(offsetof(GUIComponent, m_custom_width) == 0x48); + static_assert(offsetof(GUIComponent, m_custom_height) == 0x4C); + static_assert(offsetof(GUIComponent, m_id) == 0x5'38); + static_assert(sizeof(GUIComponent) == 0x5'40); + + // Byte offset of GUIComponentButton::mOwner (MenuScreen*), set after construction. + inline constexpr std::size_t gui_component_button_owner_offset = 0x5'A0; + inline constexpr std::size_t gui_component_button_size = 0x5'B0; + + // sgg::MenuScreen, the base of MiscSettingsScreen. mComponents owns every live widget + // that is drawn and hit-tested; freed components are dropped from it. mAnchor is the + // base location the engine gives freshly created option components. + struct MenuScreen + { + char m_pad_anchor[0x50]; + Vec2 m_anchor; // +0x50 + char m_pad_mo[0x68]; + GUIComponent* m_mouse_over_component; // +0xC0 + eastl_vector m_components; // +0xC8 + char m_pad_sel[0xD0]; + GUIComponent* m_selected_component; // +0x1B0 + }; + + static_assert(offsetof(MenuScreen, m_anchor) == 0x50); + static_assert(offsetof(MenuScreen, m_mouse_over_component) == 0xC0); + static_assert(offsetof(MenuScreen, m_components) == 0xC8); + static_assert(offsetof(MenuScreen, m_selected_component) == 0x1'B0); + + // sgg::MiscSettingsScreen, the native tabbed options screen. The category buttons are + // laid out contiguously from +0x388 (Gameplay) to +0x3F8 (Debug); the non-user + // categories such as Editor follow the eight user-facing ones. mOptions holds the + // current category's option components. + struct MiscSettingsScreen + { + char m_pad_psi[0x3'44]; + std::uint32_t m_page_start_index; // +0x344 + std::uint32_t m_options_per_page; // +0x348 + char m_pad_cf[0x04]; + GUIComponent* m_component_focused; // +0x350 + GUIComponent* m_current_category_button; // +0x358 + GUIComponent* m_last_option_button; // +0x360 + char m_pad_a[0x20]; + GUIComponentButton* m_gameplay_options_button; // +0x388 + char m_pad_b[0x30]; + GUIComponentButton* m_credits_options_button; // +0x3C0 + GUIComponentButton* m_editor_options_button; // +0x3C8 + char m_pad_c[0x28]; + GUIComponentButton* m_debug_options_button; // +0x3F8 + char m_pad_d[0x08]; + eastl_vector m_options; // +0x408 + char m_pad_e[0x40]; + GUIComponent* m_description_box; // +0x460 + }; + + static_assert(offsetof(MiscSettingsScreen, m_page_start_index) == 0x3'44); + static_assert(offsetof(MiscSettingsScreen, m_options_per_page) == 0x3'48); + static_assert(offsetof(MiscSettingsScreen, m_component_focused) == 0x3'50); + static_assert(offsetof(MiscSettingsScreen, m_current_category_button) == 0x3'58); + static_assert(offsetof(MiscSettingsScreen, m_last_option_button) == 0x3'60); + static_assert(offsetof(MiscSettingsScreen, m_gameplay_options_button) == 0x3'88); + static_assert(offsetof(MiscSettingsScreen, m_credits_options_button) == 0x3'C0); + static_assert(offsetof(MiscSettingsScreen, m_editor_options_button) == 0x3'C8); + static_assert(offsetof(MiscSettingsScreen, m_debug_options_button) == 0x3'F8); + static_assert(offsetof(MiscSettingsScreen, m_options) == 0x4'08); + static_assert(offsetof(MiscSettingsScreen, m_description_box) == 0x4'60); +} // namespace big::mod_settings::sgg From af2a326cdb691255d7c17b233b92f101c4b5c5e8 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:27:22 +0100 Subject: [PATCH 003/100] Add native rom.mod_settings.load config helper (drop-in Chalk replacement) --- src/hades2/mod_settings/config_api.cpp | 176 ++++++++++++++++ src/hades2/mod_settings/mod_settings.cpp | 203 ++++++++++++++++--- src/hades2/mod_settings/mod_settings.hpp | 1 + src/lua_extensions/lua_manager_extension.cpp | 57 +++--- 4 files changed, 379 insertions(+), 58 deletions(-) create mode 100644 src/hades2/mod_settings/config_api.cpp diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp new file mode 100644 index 0000000..5b63461 --- /dev/null +++ b/src/hades2/mod_settings/config_api.cpp @@ -0,0 +1,176 @@ +#include "mod_settings.hpp" + +#include +#include +#include + +// clang-format off +#include +using namespace al; +// clang-format on +#undef ERROR + +namespace big::mod_settings +{ + // Merge + read/write proxy, embedded Lua loaded once at init. It operates purely on a + // config_file created on the C++ side and returns the `bind(config_file, defaults, + // descriptions)` function. It binds under section "config" so the .cfg stays byte-compatible + // with what SGG_Modding-Chalk wrote (and with r2modman). config_file:bind adopts a value + // already saved in the .cfg, preserving user edits. + static constexpr const char* g_helper_lua = R"LUA( +local flat_types = { string = true, number = true, boolean = true } +local section_root = "config" + +local function find_entry(config_file, section, key) + for def, entry in pairs(config_file.entries) do + if def.section == section and def.key == key then + return entry + end + end + return nil +end + +local function has_section(config_file, section) + local prefix = section .. "." + for def in pairs(config_file.entries) do + if def.section == section or def.section:sub(1, #prefix) == prefix then + return true + end + end + return false +end + +local function describe(desc) + if type(desc) == "string" then return desc end + if type(desc) == "table" then return desc.description or desc[1] or "" end + return "" +end + +local function bind_defaults(config_file, defaults, desc, section) + for k, v in pairs(defaults) do + local key = tostring(k) + local t = type(v) + local d = desc and desc[k] + if t == "table" then + bind_defaults(config_file, v, (type(d) == "table") and d or nil, section .. "." .. key) + elseif flat_types[t] then + config_file:bind(section, key, v, describe(d)) + end + end +end + +local function make_proxy(config_file, section) + return setmetatable({}, { + __index = function(_, k) + local key = tostring(k) + local entry = find_entry(config_file, section, key) + if entry then return entry:get() end + local child = section .. "." .. key + if has_section(config_file, child) then return make_proxy(config_file, child) end + return nil + end, + __newindex = function(_, k, v) + local entry = find_entry(config_file, section, tostring(k)) + if entry then entry:set(v) end + end, + }) +end + +return function(config_file, defaults, descriptions) + bind_defaults(config_file, defaults, descriptions or {}, section_root) + config_file:save() + return make_proxy(config_file, section_root) +end +)LUA"; + + static sol::protected_function g_bind; + + // rom.mod_settings.load(config_lua): native replacement for chalk.auto. Uses the calling + // mod (this_environment) to derive its /.cfg path and create a native + // config_file owned by that mod, loads the mod's config.lua, then binds via the embedded + // helper and returns a read/write proxy. + static sol::object load(sol::this_state ts, sol::this_environment this_env, const std::string& config_lua) + { + if (!this_env || !g_bind.valid()) + { + return sol::lua_nil; + } + + sol::state_view state = ts; + sol::environment env = this_env; + + auto* module = big::lua_module::this_from(this_env); + if (!module) + { + return sol::lua_nil; + } + const std::string guid = module->guid(); + + // .cfg path = rom.path.combine(rom.paths.config(), guid .. ".cfg") - identical to the + // path Chalk used, so an existing .cfg is reused. + sol::table rom = env["rom"]; + sol::function path_combine = rom["path"]["combine"]; + sol::function config_folder = rom["paths"]["config"]; + const std::string cfg_folder = config_folder(); + const std::string cfg_path = path_combine(cfg_folder, guid + ".cfg"); + + // Create the config_file owned by this mod (freed when the mod unloads). + auto& cf = module->m_data.m_config_files.emplace_back(std::make_unique(cfg_path, true, guid)); + + // Load the mod's config.lua (returns `config, configDesc`), relative to its folder. + const std::string mod_folder = env["_PLUGIN"]["plugins_mod_folder_path"]; + const std::string config_lua_path = mod_folder + "/" + config_lua; + + sol::load_result loaded = state.load_file(config_lua_path); + if (!loaded.valid()) + { + sol::error err = loaded; + LOG(WARNING) << "[mod_settings] load: cannot load " << config_lua_path << ": " << err.what(); + return sol::lua_nil; + } + sol::protected_function config_chunk = loaded; + sol::set_environment(env, config_chunk); + sol::protected_function_result cfg_result = config_chunk(); + if (!cfg_result.valid()) + { + sol::error err = cfg_result; + LOG(WARNING) << "[mod_settings] load: error running " << config_lua_path << ": " << err.what(); + return sol::lua_nil; + } + sol::object defaults = cfg_result[0]; + sol::object descriptions = cfg_result[1]; + + sol::object cf_obj = sol::make_object(ts, cf.get()); + sol::protected_function_result pr = g_bind(cf_obj, defaults, descriptions); + if (!pr.valid()) + { + sol::error err = pr; + LOG(WARNING) << "[mod_settings] load: bind failed: " << err.what(); + return sol::lua_nil; + } + return pr; + } + + void bind_config_api(sol::state_view& state, sol::table& lua_ext) + { + sol::load_result loaded = state.load(g_helper_lua, "@h2m_mod_settings_helper"); + if (!loaded.valid()) + { + sol::error err = loaded; + LOG(WARNING) << "[mod_settings] failed to load embedded config helper: " << err.what(); + return; + } + sol::protected_function chunk = loaded; + sol::protected_function_result bind_maker = chunk(); + if (!bind_maker.valid()) + { + sol::error err = bind_maker; + LOG(WARNING) << "[mod_settings] failed to init embedded config helper: " << err.what(); + return; + } + g_bind = bind_maker; + + sol::table ns = lua_ext.create_named("mod_settings"); + ns.set_function("load", &load); + } +} // namespace big::mod_settings diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 7f8282e..ea3ddba 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -123,13 +123,17 @@ namespace big::mod_settings // ScreenCenterOffsetY, and X = the row's own location. Rows mirror the key-rebind // ControlButton layout: the component is anchored to the right pane and its text is // left-justified via a negative text offset, matching the native option-name column. - static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton - static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column + static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton + static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column + static constexpr float value_text_offset_x = 55.0f; // right-justify the value; aligns with the toggle indicator column static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) static constexpr float row_base_y = 315.0f; // first row's Y (aligns with the tab column) static constexpr float row_pitch = 54.0f; // vertical distance between rows static constexpr std::uint32_t rows_per_page = 8; + // Edit-cursor blink half-period (ms): the "|" shows for this long, then hides. + static constexpr std::uint64_t edit_cursor_blink_ms = 500; + // What a panel row represents, so a click can be routed to the right action. enum class RowKind { @@ -152,6 +156,10 @@ namespace big::mod_settings bool disabled = false; // greyed & non-interactable (mod disabled) bool is_enabled_toggle = false; // the mod's master "enabled" toggle + + // Right-column value display for a non-bool setting row (paired with `component`, the + // left-column key). Not in mOptions; positioned to follow `component` each frame. + GUIComponent* value_component = nullptr; }; static std::vector g_rows; @@ -176,7 +184,7 @@ namespace big::mod_settings static bool g_editing = false; static GUIComponent* g_edit_component = nullptr; static toml_v2::config_file::config_entry_base* g_edit_entry = nullptr; - static std::string g_edit_key; + static std::string g_edit_buffer; static bool g_edit_numeric = false; // restrict input to a numeric literal static bool g_edit_confirm = false; @@ -251,12 +259,15 @@ namespace big::mod_settings // vectors, sets its X, and starts it transparent. UpdateScrollState only fades in and // repositions on-page rows, so off-page rows must start invisible to avoid flashing // stacked at the top. - static void finalize_row(MiscSettingsScreen* screen, GUIComponent* row) + static void finalize_row(MiscSettingsScreen* screen, GUIComponent* row, bool in_options = true) { GUIComponent* value = row; auto* menu = reinterpret_cast(screen); g_push_back(&menu->m_components, &value); - g_push_back(&screen->m_options, &value); + if (in_options) + { + g_push_back(&screen->m_options, &value); + } row->m_location_x = row_location_x; row->m_fade_opacity = 0.0f; @@ -292,6 +303,17 @@ namespace big::mod_settings *reinterpret_cast(def + def_sel_text_blue) = grey; } + // Forces a row's normal text colour to full white so plain-text (key/value) rows read as + // bright/editable, matching the toggle rows. The selected colour is left as the template's + // so hover still highlights. Must run before SetupComponent to reach the text box. + static void set_def_text_white(GUIComponent* row) + { + char* def = reinterpret_cast(row) + component_def_offset; + *reinterpret_cast(def + def_text_red) = 1.0f; + *reinterpret_cast(def + def_text_green) = 1.0f; + *reinterpret_cast(def + def_text_blue) = 1.0f; + } + // A plain left-justified text row (mod names, Back, and non-toggle settings). Applies a // template for a valid font/colours, then retunes the row's own def into the key-rebind // "ControlButton" style - no background graphic, left text, and a text-area hit region @@ -326,6 +348,10 @@ namespace big::mod_settings { set_def_text_grey(row); } + else + { + set_def_text_white(row); + } if (g_setup_component) { @@ -469,6 +495,73 @@ namespace big::mod_settings return row; } + // A right-justified, non-interactive value label for the right column of a key/value + // setting row (paired with a left-column key row). It is NOT added to mOptions: the + // engine's scroll pass lays out only mOptions rows by index and would stack a second + // per-row entry, so instead the value follows its key row each frame (sync_value_columns). + // It shares the key's component X anchor but uses RIGHT justification, so the value sits in + // the right column while the key stays left. + static GUIComponent* make_value_display(MiscSettingsScreen* screen, const char* text, bool disabled) + { + auto* row = create_button(screen); + if (!row) + { + return nullptr; + } + auto* row_bytes = reinterpret_cast(row); + + set_sso_string(row_bytes + gui_component_name_offset, "CategoryOptionsButton"); + g_apply_data(reinterpret_cast(screen), row); + + char* def = row_bytes + component_def_offset; + *reinterpret_cast(def + def_add_text_area) = 0; // display only: no hit area + *reinterpret_cast(def + def_use_text_area) = 0; + *reinterpret_cast(def + def_graphic) = 0; + *reinterpret_cast(def + def_selected_graphic) = 0; + *reinterpret_cast(def + def_alternate_graphic) = 0; + *reinterpret_cast(def + def_width) = 0.0f; + *reinterpret_cast(def + def_height) = 0.0f; + *reinterpret_cast(def + def_text_justification) = 1; // sgg::Justification::RIGHT + *reinterpret_cast(def + def_text_offset_x) = value_text_offset_x; + *reinterpret_cast(def + def_y) = row_base_y; + *reinterpret_cast(def + def_spacing) = row_pitch; + + if (disabled) + { + set_def_text_grey(row); + } + else + { + set_def_text_white(row); + } + + if (g_setup_component) + { + g_setup_component(row, row_bytes + component_data_offset); + } + if (g_set_normal_texture) + { + g_set_normal_texture(row, 0, false); + } + if (g_set_selected_texture) + { + g_set_selected_texture(row, 0); + } + if (g_set_animation && g_blank_graphic) + { + g_set_animation(row, g_blank_graphic); + } + if (g_set_label) + { + g_set_label(row, text); + } + + row->m_can_be_focused = false; // never interactive; the empty hit area blocks hover/click + + finalize_row(screen, row, false); // drawn (mComponents) but not paged (mOptions) + return row; + } + // Removes the first pointer equal to `value` from an eastl vector by shifting the tail // down in place - the same unlink the engine's DoShowCategory performs. No-op if not // present; the backing storage is left owned by the vector. @@ -494,14 +587,12 @@ namespace big::mod_settings { auto* menu = reinterpret_cast(screen); - for (const auto& row : g_rows) + auto unlink_and_free = [&](GUIComponent* comp, bool in_options) { - GUIComponent* comp = row.component; if (!comp) { - continue; + return; } - if (menu->m_mouse_over_component == comp) { menu->m_mouse_over_component = nullptr; @@ -518,15 +609,28 @@ namespace big::mod_settings { screen->m_last_option_button = nullptr; } + if (g_edit_component == comp) + { + g_edit_component = nullptr; + } vector_erase(menu->m_components, comp); - vector_erase(screen->m_options, comp); + if (in_options) + { + vector_erase(screen->m_options, comp); + } if (g_button_dtor) { g_button_dtor(comp); } _aligned_free(comp); + }; + + for (const auto& row : g_rows) + { + unlink_and_free(row.component, true); + unlink_and_free(row.value_component, false); } g_rows.clear(); @@ -578,12 +682,6 @@ namespace big::mod_settings return display; } - // "Key : value" label for a non-toggle setting row. - static std::string setting_label(const std::string& key, toml_v2::config_file::config_entry_base* entry) - { - return key_to_display(key) + " : " + (entry ? entry->get_serialized_value() : std::string{}); - } - // Accepts a character into a numeric edit buffer only if the result stays a plausible // numeric literal: an optional leading sign, digits, at most one decimal point. static bool numeric_char_ok(const std::string& buffer, char c) @@ -664,13 +762,13 @@ namespace big::mod_settings registered = true; } - static void enter_edit_mode(GUIComponent* component, toml_v2::config_file::config_entry_base* entry, const std::string& key) + // Enters freetext edit on `value_component` (the row's right-column value display). + static void enter_edit_mode(GUIComponent* value_component, toml_v2::config_file::config_entry_base* entry) { ensure_wndproc_registered(); g_editing = true; - g_edit_component = component; + g_edit_component = value_component; g_edit_entry = entry; - g_edit_key = key; g_edit_buffer = entry ? entry->get_serialized_value() : std::string{}; g_edit_numeric = entry && entry->type() != typeid(std::string); g_edit_confirm = false; @@ -722,13 +820,14 @@ namespace big::mod_settings return false; } - // Live-updates the edited row's label with a trailing cursor. Called from Update while - // editing is still active. + // Live-updates the edited value display (right column) with a blinking cursor. Called from + // Update while editing is active; g_edit_component is the row's value component. static void update_edit_label() { if (g_edit_component && g_set_label) { - const std::string label = key_to_display(g_edit_key) + " : " + g_edit_buffer + "|"; + const bool cursor_on = ((GetTickCount64() / edit_cursor_blink_ms) % 2) == 0; + const std::string label = g_edit_buffer + (cursor_on ? "|" : " "); g_set_label(g_edit_component, label.c_str()); } } @@ -740,9 +839,10 @@ namespace big::mod_settings } // Level 2: a Back row followed by one row per config entry belonging to `stem`. Boolean - // entries render as native toggle rows; other types render as "key : value" text rows. - // A boolean "enabled" entry (if present) is pinned to the top; when it is off, every - // other setting is greyed out and made non-interactable. + // entries render as native toggle rows; other types render as a left-aligned key with a + // right-aligned, freetext-editable value (two components). A boolean "enabled" entry (if + // present) is pinned to the top; when it is off, every other setting is greyed out and + // made non-interactable. static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem) { if (auto* row = make_text_row(screen, "< Back")) @@ -788,14 +888,21 @@ namespace big::mod_settings const bool is_enabled_row = (entry == enabled_entry); const bool disabled = !is_enabled_row && !mod_enabled; - GUIComponent* row = nullptr; + GUIComponent* row = nullptr; + GUIComponent* value = nullptr; if (entry->type() == typeid(bool)) { row = make_toggle_row(screen, key_to_display(key).c_str(), entry->get_value_base(), disabled); } else { - row = make_text_row(screen, setting_label(key, entry).c_str(), disabled); + // Left-aligned key + right-aligned value (two components), like a keybind row. + row = make_text_row(screen, key_to_display(key).c_str(), disabled); + if (row) + { + const std::string v = entry ? entry->get_serialized_value() : std::string{}; + value = make_value_display(screen, v.c_str(), disabled); + } } if (row) @@ -803,11 +910,33 @@ namespace big::mod_settings PanelRow pr{row, RowKind::setting, stem, key, entry}; pr.disabled = disabled; pr.is_enabled_toggle = is_enabled_row; + pr.value_component = value; g_rows.push_back(pr); } } } + // Value displays are not in mOptions, so the engine's scroll pass does not lay them out. + // Mirror each value component onto its key row's current position and fade so the right + // column tracks scrolling and fade-in/out. + static void sync_value_columns() + { + for (const auto& row : g_rows) + { + GUIComponent* key = row.component; + GUIComponent* value = row.value_component; + if (!key || !value) + { + continue; + } + value->m_location_x = key->m_location_x; + value->m_location_y = key->m_location_y; + value->m_fade_opacity = key->m_fade_opacity; + value->m_fade_target = key->m_fade_target; + value->m_hidden = key->m_hidden; + } + } + static void build_panel(MiscSettingsScreen* screen, bool instant = false) { // Preserve the current scroll offset across an in-place refresh (same view/mod, e.g. @@ -868,6 +997,9 @@ namespace big::mod_settings } } } + + // Value displays are not laid out by the scroll pass; place them on their key rows now. + sync_value_columns(); } // Applies a queued navigation (mod list <-> a mod's settings) by rebuilding the panel. @@ -941,8 +1073,8 @@ namespace big::mod_settings { RowKind kind = RowKind::mod_entry; std::string stem; - std::string setting_key; toml_v2::config_file::config_entry_base* entry = nullptr; + GUIComponent* value_component = nullptr; bool matched = false; bool disabled = false; bool is_enabled_toggle = false; @@ -955,8 +1087,8 @@ namespace big::mod_settings { kind = row.kind; stem = row.stem; - setting_key = row.setting_key; entry = row.entry; + value_component = row.value_component; disabled = row.disabled; is_enabled_toggle = row.is_enabled_toggle; matched = true; @@ -1000,7 +1132,7 @@ namespace big::mod_settings } else if (entry) { - enter_edit_mode(self, entry, setting_key); + enter_edit_mode(value_component, entry); } break; case RowKind::action: @@ -1045,7 +1177,16 @@ namespace big::mod_settings g_nav_pending = false; } - return big::g_hooking->get_original()(self, dt, input); + void* result = big::g_hooking->get_original()(self, dt, input); + + // The original just laid out the key rows for this frame; mirror the value columns + // onto them so the right column tracks scrolling and fade. + if (on_mods_tab) + { + sync_value_columns(); + } + + return result; } // While a freetext setting is being edited, read Enter (confirm) and Escape (cancel) diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index c235cfa..135d8d9 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -3,4 +3,5 @@ namespace big::mod_settings { void register_hooks(); + void bind_config_api(sol::state_view& state, sol::table& lua_ext); } // namespace big::mod_settings diff --git a/src/lua_extensions/lua_manager_extension.cpp b/src/lua_extensions/lua_manager_extension.cpp index c1ce709..32f9b41 100644 --- a/src/lua_extensions/lua_manager_extension.cpp +++ b/src/lua_extensions/lua_manager_extension.cpp @@ -4,16 +4,18 @@ #include "bindings/hades/audio.hpp" #include "bindings/hades/data.hpp" #include "bindings/hades/draw.hpp" -#include "bindings/hades/inputs.hpp" #include "bindings/hades/gpk.hpp" +#include "bindings/hades/inputs.hpp" #include "bindings/hades/tethers.hpp" #include "bindings/lpeg.hpp" #include "bindings/luasocket/luasocket.hpp" #include "bindings/paths_ext.hpp" #include "bindings/tolk/tolk.hpp" #include "lua_module_ext.hpp" -#include + +#include #include +#include std::wstring utf8_to_wstring(const std::string &utf8_str); @@ -32,20 +34,20 @@ namespace big::lua_manager_extension LOG(INFO) << "state is no longer valid!"; } - static int the_state_is_going_down(lua_State* L) + static int the_state_is_going_down(lua_State *L) { delete_everything(); return 0; } - void init_lua_manager(sol::state_view& state, sol::table& lua_ext) + void init_lua_manager(sol::state_view &state, sol::table &lua_ext) { init_lua_state(state, lua_ext); init_lua_api(state, lua_ext); } - static int open_debug_lib(lua_State* L) + static int open_debug_lib(lua_State *L) { luaL_requiref(L, "_rom_debug", luaopen_debug, 1 /*Leaves a copy of the module on the stack.*/); @@ -55,12 +57,12 @@ namespace big::lua_manager_extension // Mods listed here may use all blocked functions // Use the mod GUID as it appears in the plugins folder ("AuthorName-ModName") - static constexpr const char* allowlisted_mods[] = { - "Enderclem-CG3HBuilder", - "zerp-MelSkin", + static constexpr const char *allowlisted_mods[] = { + "Enderclem-CG3HBuilder", + "zerp-MelSkin", }; - static bool is_mod_allowlisted(const char* source) + static bool is_mod_allowlisted(const char *source) { if (!source) { @@ -68,7 +70,7 @@ namespace big::lua_manager_extension } // Source paths look like: @.../plugins/AuthorName-ModName/file.lua - const char* plugins_pos = strstr(source, "plugins\\"); + const char *plugins_pos = strstr(source, "plugins\\"); if (!plugins_pos) { plugins_pos = strstr(source, "plugins/"); @@ -78,8 +80,8 @@ namespace big::lua_manager_extension return false; } - const char* mod_start = plugins_pos + 8; - const char* mod_end = mod_start; + const char *mod_start = plugins_pos + 8; + const char *mod_end = mod_start; while (*mod_end && *mod_end != '/' && *mod_end != '\\') { mod_end++; @@ -87,7 +89,7 @@ namespace big::lua_manager_extension size_t mod_len = mod_end - mod_start; - for (const auto& allowed : allowlisted_mods) + for (const auto &allowed : allowlisted_mods) { if (strlen(allowed) == mod_len && strncmp(mod_start, allowed, mod_len) == 0) { @@ -99,7 +101,7 @@ namespace big::lua_manager_extension } // Upvalue 1: function name string, Upvalue 2: original function - static int blocked_lua_function(lua_State* L) + static int blocked_lua_function(lua_State *L) { // Check if the direct caller is an allowlisted mod lua_Debug ar; @@ -117,25 +119,25 @@ namespace big::lua_manager_extension } } - const char* name = lua_tostring(L, lua_upvalueindex(1)); + const char *name = lua_tostring(L, lua_upvalueindex(1)); return luaL_error(L, "%s() is not available", name); } struct sandbox_entry { - const char* table; // table name, or nullptr for globals - const char* field; // function name within the table (or global name) + const char *table; // table name, or nullptr for globals + const char *field; // function name within the table (or global name) }; static constexpr sandbox_entry blocked_functions[] = { - {"os", "execute"}, - {"io", "popen"}, - {"package", "loadlib"}, + {"os", "execute"}, + {"io", "popen"}, + {"package", "loadlib"}, }; - static void sandbox_lua_state(lua_State* L) + static void sandbox_lua_state(lua_State *L) { - for (const auto& entry : blocked_functions) + for (const auto &entry : blocked_functions) { if (entry.table) { @@ -204,7 +206,7 @@ namespace big::lua_manager_extension #endif - static int io_open_utf8(lua_State* L) + static int io_open_utf8(lua_State *L) { const char *filename = luaL_checkstring(L, 1); const char *mode = luaL_optstring(L, 2, "r"); @@ -342,7 +344,7 @@ namespace big::lua_manager_extension return status; } - void init_lua_state(sol::state_view& state, sol::table& lua_ext) + void init_lua_state(sol::state_view &state, sol::table &lua_ext) { // Register our cleanup functions when the state get destroyed. { @@ -394,7 +396,7 @@ namespace big::lua_manager_extension } } - void init_lua_api(sol::state_view& state, sol::table& lua_ext) + void init_lua_api(sol::state_view &state, sol::table &lua_ext) { auto on_import_table = lua_ext.create_named("on_import"); @@ -407,7 +409,7 @@ namespace big::lua_manager_extension on_import_table.set_function("pre", [](sol::protected_function f, sol::this_environment env) { - auto mod = (lua_module_ext*)lua_module::this_from(env); + auto mod = (lua_module_ext *)lua_module::this_from(env); if (mod) { mod->m_data_ext.m_on_pre_import.push_back(f); @@ -422,7 +424,7 @@ namespace big::lua_manager_extension on_import_table.set_function("post", [](sol::protected_function f, sol::this_environment env) { - auto mod = (lua_module_ext*)lua_module::this_from(env); + auto mod = (lua_module_ext *)lua_module::this_from(env); if (mod) { mod->m_data_ext.m_on_post_import.push_back(f); @@ -441,5 +443,6 @@ namespace big::lua_manager_extension lua::gui_ext::bind(lua_ext); lua::lpeg::bind(lua_ext); lua::paths_ext::bind(lua_ext); + big::mod_settings::bind_config_api(state, lua_ext); } } // namespace big::lua_manager_extension From 152d794e3c60c547024006eb383e4cfc1ec250e5 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:30:05 +0100 Subject: [PATCH 004/100] Port config helper to native C++ (mod_config_proxy usertype); native restart-required popup --- src/hades2/mod_settings/config_api.cpp | 343 ++++++++++++++------- src/hades2/mod_settings/mod_settings.cpp | 368 +++++++++++++++++++++-- src/hades2/mod_settings/mod_settings.hpp | 7 + 3 files changed, 591 insertions(+), 127 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 5b63461..3de80d0 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -2,7 +2,11 @@ #include #include +#include +#include #include +#include +#include // clang-format off #include @@ -12,86 +16,224 @@ using namespace al; namespace big::mod_settings { - // Merge + read/write proxy, embedded Lua loaded once at init. It operates purely on a - // config_file created on the C++ side and returns the `bind(config_file, defaults, - // descriptions)` function. It binds under section "config" so the .cfg stays byte-compatible - // with what SGG_Modding-Chalk wrote (and with r2modman). config_file:bind adopts a value - // already saved in the .cfg, preserving user edits. - static constexpr const char* g_helper_lua = R"LUA( -local flat_types = { string = true, number = true, boolean = true } -local section_root = "config" - -local function find_entry(config_file, section, key) - for def, entry in pairs(config_file.entries) do - if def.section == section and def.key == key then - return entry - end - end - return nil -end - -local function has_section(config_file, section) - local prefix = section .. "." - for def in pairs(config_file.entries) do - if def.section == section or def.section:sub(1, #prefix) == prefix then - return true - end - end - return false -end - -local function describe(desc) - if type(desc) == "string" then return desc end - if type(desc) == "table" then return desc.description or desc[1] or "" end - return "" -end - -local function bind_defaults(config_file, defaults, desc, section) - for k, v in pairs(defaults) do - local key = tostring(k) - local t = type(v) - local d = desc and desc[k] - if t == "table" then - bind_defaults(config_file, v, (type(d) == "table") and d or nil, section .. "." .. key) - elseif flat_types[t] then - config_file:bind(section, key, v, describe(d)) - end - end -end - -local function make_proxy(config_file, section) - return setmetatable({}, { - __index = function(_, k) - local key = tostring(k) - local entry = find_entry(config_file, section, key) - if entry then return entry:get() end - local child = section .. "." .. key - if has_section(config_file, child) then return make_proxy(config_file, child) end - return nil - end, - __newindex = function(_, k, v) - local entry = find_entry(config_file, section, tostring(k)) - if entry then entry:set(v) end - end, - }) -end - -return function(config_file, defaults, descriptions) - bind_defaults(config_file, defaults, descriptions or {}, section_root) - config_file:save() - return make_proxy(config_file, section_root) -end -)LUA"; - - static sol::protected_function g_bind; - - // rom.mod_settings.load(config_lua): native replacement for chalk.auto. Uses the calling - // mod (this_environment) to derive its /.cfg path and create a native - // config_file owned by that mod, loads the mod's config.lua, then binds via the embedded - // helper and returns a read/write proxy. + // Author-declared per-setting metadata registry, populated from each mod's config.lua by + // rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Currently records only + // whether a setting requires a game restart to take effect (set via `restart_required = true` + // in a setting's config.lua description table); extensible to context/visibility later. This + // replaces the old sjson-hook auto-detection, which could not see a mod that starts disabled + // (it registers no hooks until enabled), and never covered restarts needed for other reasons. + static std::mutex g_metadata_mutex; + static std::map g_restart_required_settings; + + static std::string metadata_key(const std::string& guid, const std::string& section, const std::string& key) + { + std::string k; + k.reserve(guid.size() + section.size() + key.size() + 2); + k.append(guid); + k.push_back('\0'); + k.append(section); + k.push_back('\0'); + k.append(key); + return k; + } + + // Drops a mod's metadata before it re-registers: config.lua may change between loads, and the + // Lua state is recreated on App::Reset (so load runs again for every mod). + static void clear_metadata_for(const std::string& guid) + { + const std::string prefix = guid + '\0'; + for (auto it = g_restart_required_settings.begin(); it != g_restart_required_settings.end();) + { + it = (it->first.rfind(prefix, 0) == 0) ? g_restart_required_settings.erase(it) : std::next(it); + } + } + + bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key) + { + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_restart_required_settings.find(metadata_key(guid, section, key)); + return it != g_restart_required_settings.end() && it->second; + } + + // Extracts a description string from a config.lua description value, which may be a plain string + // or a table with a `description` field (or `[1]` shorthand). + static std::string describe(const sol::object& desc) + { + if (desc.get_type() == sol::type::string) + { + return desc.as(); + } + if (desc.is()) + { + sol::table t = desc.as(); + sol::object as_field = t["description"]; + if (as_field.get_type() == sol::type::string) + { + return as_field.as(); + } + sol::object as_first = t[1]; + if (as_first.get_type() == sol::type::string) + { + return as_first.as(); + } + } + return ""; + } + + // True if a config.lua description table declares `restart_required = true`. + static bool description_requires_restart(const sol::object& desc) + { + if (!desc.is()) + { + return false; + } + sol::object flag = desc.as()["restart_required"]; + return flag.is() && flag.as(); + } + + // Finds the config entry for (section, key), or nullptr. m_entries is keyed by config_definition, + // so this is a direct map lookup. + static toml_v2::config_file::config_entry_base* find_entry(toml_v2::config_file* cf, const std::string& section, const std::string& key) + { + toml_v2::config_definition def(section, key); + return cf->try_get_entry(def); + } + + // True if `section` is a bound section or the parent of one (some entry's section equals + // `section` or starts with `section + "."`). Used to expose nested config tables via the proxy. + static bool has_section(toml_v2::config_file* cf, const std::string& section) + { + const std::string prefix = section + "."; + for (const auto& [def, entry] : cf->m_entries) + { + if (def.m_section == section || def.m_section.rfind(prefix, 0) == 0) + { + return true; + } + } + return false; + } + + // Reads a config entry's value as the matching Lua type. + static sol::object entry_get(sol::this_state ts, toml_v2::config_file::config_entry_base* entry) + { + const auto& t = entry->type(); + if (t == typeid(bool)) + { + return sol::make_object(ts, entry->get_value_base()); + } + if (t == typeid(double)) + { + return sol::make_object(ts, entry->get_value_base()); + } + if (t == typeid(std::string)) + { + return sol::make_object(ts, entry->get_value_base()); + } + return sol::lua_nil; + } + + // Writes a Lua value into a config entry, dispatching on the value's Lua type (matching the + // toml_v2 config_entry:set overloads: bool/number/string). + static void entry_set(toml_v2::config_file::config_entry_base* entry, const sol::object& value) + { + switch (value.get_type()) + { + case sol::type::boolean: entry->set_value_base(value.as()); break; + case sol::type::number: entry->set_value_base(value.as()); break; + case sol::type::string: entry->set_value_base(value.as()); break; + default: break; + } + } + + // Live read/write view over a config_file section, returned to the mod as its `config` object. + // Reads/writes go straight through to the underlying config entries (so the in-game menu and the + // mod always see the same values); nested sections resolve to child proxies. It holds a raw + // config_file pointer (not a sol reference): the config_file is owned by the mod and both it and + // this proxy are recreated together per Lua state, so nothing dangles across an App::Reset. + struct mod_config_proxy + { + toml_v2::config_file* cf = nullptr; + std::string section; + + sol::object index(sol::this_state ts, const std::string& key) const + { + if (auto* entry = find_entry(cf, section, key)) + { + return entry_get(ts, entry); + } + const std::string child = section + "." + key; + if (has_section(cf, child)) + { + return sol::make_object(ts, mod_config_proxy{cf, child}); + } + return sol::lua_nil; + } + + void new_index(const std::string& key, const sol::object& value) const + { + if (auto* entry = find_entry(cf, section, key)) + { + entry_set(entry, value); + } + } + }; + + // Recursively binds a config.lua `defaults` table into `cf` under `section`, forwarding each + // leaf's description. Nested tables become sub-sections ("section.key"). Leaf keys whose + // description declares restart_required are appended to `restart_out` as (section, key) pairs. + // config_file::bind adopts a value already saved in the .cfg, preserving user edits, and binds + // under section "config" so the .cfg stays byte-compatible with what SGG_Modding-Chalk wrote. + static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector>& restart_out) + { + sol::table desc_tbl; + const bool has_desc = desc_obj.is(); + if (has_desc) + { + desc_tbl = desc_obj.as(); + } + + for (const auto& [key_obj, value_obj] : defaults) + { + if (key_obj.get_type() != sol::type::string) + { + continue; + } + const std::string key = key_obj.as(); + + sol::object desc = sol::lua_nil; + if (has_desc) + { + desc = desc_tbl[key]; + } + + const sol::type vt = value_obj.get_type(); + switch (vt) + { + case sol::type::table: + bind_defaults(cf, value_obj.as(), desc, section + "." + key, restart_out); + break; + case sol::type::boolean: cf->bind(section, key, value_obj.as(), describe(desc)); break; + case sol::type::number: cf->bind(section, key, value_obj.as(), describe(desc)); break; + case sol::type::string: cf->bind(section, key, value_obj.as(), describe(desc)); break; + default: continue; + } + + if (vt != sol::type::table && description_requires_restart(desc)) + { + restart_out.emplace_back(section, key); + } + } + } + + // rom.mod_settings.load(config_lua): native replacement for chalk.auto. Uses the calling mod + // (this_environment) to derive its /.cfg path and create a native + // config_file owned by that mod, loads the mod's config.lua, binds its defaults/descriptions + // into that config_file, records any restart-required settings, and returns a live read/write + // proxy over the config. static sol::object load(sol::this_state ts, sol::this_environment this_env, const std::string& config_lua) { - if (!this_env || !g_bind.valid()) + if (!this_env) { return sol::lua_nil; } @@ -140,35 +282,34 @@ end sol::object defaults = cfg_result[0]; sol::object descriptions = cfg_result[1]; - sol::object cf_obj = sol::make_object(ts, cf.get()); - sol::protected_function_result pr = g_bind(cf_obj, defaults, descriptions); - if (!pr.valid()) + // Bind the defaults into the config_file (section root "config", matching Chalk) and collect + // the author-declared restart-required settings, then persist the file. + std::vector> restart_settings; + if (defaults.is()) { - sol::error err = pr; - LOG(WARNING) << "[mod_settings] load: bind failed: " << err.what(); - return sol::lua_nil; + bind_defaults(cf.get(), defaults.as(), descriptions, "config", restart_settings); } - return pr; + cf->save(); + + // Register this mod's restart-required settings into the metadata registry (replacing any + // from a previous load of the same mod). + { + std::scoped_lock lock(g_metadata_mutex); + clear_metadata_for(guid); + for (const auto& [section, key] : restart_settings) + { + g_restart_required_settings[metadata_key(guid, section, key)] = true; + } + } + + return sol::make_object(ts, mod_config_proxy{cf.get(), "config"}); } void bind_config_api(sol::state_view& state, sol::table& lua_ext) { - sol::load_result loaded = state.load(g_helper_lua, "@h2m_mod_settings_helper"); - if (!loaded.valid()) - { - sol::error err = loaded; - LOG(WARNING) << "[mod_settings] failed to load embedded config helper: " << err.what(); - return; - } - sol::protected_function chunk = loaded; - sol::protected_function_result bind_maker = chunk(); - if (!bind_maker.valid()) - { - sol::error err = bind_maker; - LOG(WARNING) << "[mod_settings] failed to init embedded config helper: " << err.what(); - return; - } - g_bind = bind_maker; + // Register the live-config proxy usertype once per state (mods never construct it; instances + // are returned from load). Its index/new_index read/write the underlying config entries. + lua_ext.new_usertype("mod_config_proxy", sol::no_constructor, sol::meta_function::index, &mod_config_proxy::index, sol::meta_function::new_index, &mod_config_proxy::new_index); sol::table ns = lua_ext.create_named("mod_settings"); ns.set_function("load", &load); diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index ea3ddba..e9b2eb9 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -8,7 +8,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -74,18 +76,52 @@ namespace big::mod_settings static constexpr std::size_t def_sel_text_blue = 0x1'30; // mSelectedTextBlue (float) static constexpr std::size_t def_spacing = 0x1'5C; // mSpacing (float) row pitch, read by UpdateScrollState - using ctor_fn = void* (*)(void* button, void* owner_screen); - using push_back_fn = void (*)(void* vector, GUIComponent** value); - using apply_data_fn = void (*)(void* menu_screen, GUIComponent* component); - using set_label_fn = void (*)(void* button, const char* text); - using update_scroll_fn = void (*)(void* misc_settings_screen); - using set_animation_fn = void (*)(void* button, std::uint32_t graphic_id); - using setup_component_fn = void (*)(void* component, void* component_data); - using set_texture_fn = void (*)(void* button, std::uint32_t graphic_id, bool reset); - using set_sel_texture_fn = void (*)(void* button, std::uint32_t graphic_id); - using disable_fn = void (*)(void* button); - using was_key_pressed_fn = bool (*)(void* input_handler, int keyboard_button_id); - using dtor_fn = void (*)(void* button); + // Native sgg::MessageDialog (the single-button message box the game shows in the MAIN MENU for + // save/file errors, ShellText SaveErrorPC/FileAccessErrorPC). Unlike the Lua screen system it + // does not need a loaded save, so it works when mods are toggled in the main menu. Offsets + + // RVAs DIA-validated against the current Ship Hades2.pdb. + static constexpr std::size_t message_dialog_size = 0x2'F0; // sizeof sgg::MessageDialog + static constexpr std::size_t screen_manager_offset = 0x48; // sgg::GameScreen::mScreenManager + static constexpr std::size_t screen_removed_offset = 0x21; // sgg::GameScreen::mRemoved (bool) + static constexpr std::size_t screen_visible_offset = 0x22; // sgg::GameScreen::mIsVisible (bool) + static constexpr std::size_t screen_block_input_offset = 0x24; // sgg::GameScreen::mBlockLowerInput (bool) + static constexpr std::size_t dialog_title_offset = 0x1'88; // sgg::MenuScreen::mTitleText + static constexpr std::size_t dialog_confirm_button_offset = 0x1'A0; // sgg::MenuScreen::mConfirmButton + static constexpr std::size_t dialog_message_offset = 0x2'B0; // sgg::MessageDialog::mMessageText + + // The MessageDialog.sjson MessageText template renders at FontSize 26, which is larger than we + // want for the multi-line body. The rendered size is driven by GUIComponentTextBox::mFontHandle + // (@0x6A4); scaling its mFontSizeRatio (@+0x0C) / mEnglishFontSizeRatio (@+0x10) shrinks it. The + // def's mFontSize is ignored once the sjson template is loaded, so we scale the live handle. + static constexpr std::size_t textbox_font_handle_offset = 0x6'A4; // GUIComponentTextBox::mFontHandle + static constexpr std::size_t font_handle_size_ratio_offset = 0x0C; // sgg::FontHandle::mFontSizeRatio + static constexpr std::size_t font_handle_eng_size_ratio_offset = 0x10; // sgg::FontHandle::mEnglishFontSizeRatio + static constexpr float restart_message_font_scale = 0.75f; // ~26 -> ~19.5 + + // Module-relative RVAs (current Ship build) for the overloaded functions that cannot be picked + // by name from the PDB symbol map. Resolved at runtime relative to the button-ctor anchor: + // anchor_runtime - anchor_rva + target_rva. AddScreen has three overloads; the 4-arg one + // inserts at the END of the screen list (drawn on top), unlike the 2-arg one which front-inserts + // (drawn under the full-screen options menu = invisible). + static constexpr std::uintptr_t anchor_rva = 0x11'5C'70; // sgg::GUIComponentButton::GUIComponentButton + static constexpr std::uintptr_t message_dialog_ctor_rva = 0x16'EE'60; // sgg::MessageDialog::MessageDialog(this,sm,eastl::string*) + static constexpr std::uintptr_t add_screen_rva = 0x14'7D'D0; // sgg::ScreenManager::AddScreen(this,screen,bool,eastl::string*) + + using ctor_fn = void* (*)(void* button, void* owner_screen); + using push_back_fn = void (*)(void* vector, GUIComponent** value); + using apply_data_fn = void (*)(void* menu_screen, GUIComponent* component); + using set_label_fn = void (*)(void* button, const char* text); + using update_scroll_fn = void (*)(void* misc_settings_screen); + using set_animation_fn = void (*)(void* button, std::uint32_t graphic_id); + using setup_component_fn = void (*)(void* component, void* component_data); + using set_texture_fn = void (*)(void* button, std::uint32_t graphic_id, bool reset); + using set_sel_texture_fn = void (*)(void* button, std::uint32_t graphic_id); + using disable_fn = void (*)(void* button); + using was_key_pressed_fn = bool (*)(void* input_handler, int keyboard_button_id); + using dtor_fn = void (*)(void* button); + using message_dialog_ctor_fn = void* (*)(void* self, void* screen_manager, void* eastl_message); + using add_screen_fn = void (*)(void* screen_manager, void* screen, bool add_at_end, void* eastl_name); + using show_text_fn = void (*)(void* text_box, const char* text); // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -95,19 +131,22 @@ namespace big::mod_settings using hash_lookup_fn = HashGuid* (*)(HashGuid * out, const char* str, std::size_t len); - static ctor_fn g_button_ctor = nullptr; - static push_back_fn g_push_back = nullptr; - static apply_data_fn g_apply_data = nullptr; - static set_label_fn g_set_label = nullptr; - static update_scroll_fn g_update_scroll = nullptr; - static set_animation_fn g_set_animation = nullptr; - static hash_lookup_fn g_hash_lookup = nullptr; - static setup_component_fn g_setup_component = nullptr; - static set_texture_fn g_set_normal_texture = nullptr; - static set_sel_texture_fn g_set_selected_texture = nullptr; - static dtor_fn g_button_dtor = nullptr; - static disable_fn g_disable = nullptr; - static was_key_pressed_fn g_was_key_pressed = nullptr; + static ctor_fn g_button_ctor = nullptr; + static push_back_fn g_push_back = nullptr; + static apply_data_fn g_apply_data = nullptr; + static set_label_fn g_set_label = nullptr; + static update_scroll_fn g_update_scroll = nullptr; + static set_animation_fn g_set_animation = nullptr; + static hash_lookup_fn g_hash_lookup = nullptr; + static setup_component_fn g_setup_component = nullptr; + static set_texture_fn g_set_normal_texture = nullptr; + static set_sel_texture_fn g_set_selected_texture = nullptr; + static dtor_fn g_button_dtor = nullptr; + static disable_fn g_disable = nullptr; + static was_key_pressed_fn g_was_key_pressed = nullptr; + static message_dialog_ctor_fn g_message_dialog_ctor = nullptr; + static add_screen_fn g_add_screen = nullptr; + static show_text_fn g_show_text = nullptr; // sgg::KeyboardButtonId values used for edit confirm/cancel (validated in the PDB). static constexpr int key_escape = 0; @@ -164,6 +203,26 @@ namespace big::mod_settings static std::vector g_rows; + // Set when a restart-required setting is changed this menu session (e.g. toggling the + // "enabled" switch of an sjson-backed mod). On options-menu close we warn + close the game. + static bool g_restart_required = false; + + // The restart-causing changes this session, keyed by "\0
\0" so re-editing + // the same setting overwrites its line rather than adding a duplicate. Values are the + // human-readable lines listed in the restart popup, e.g. "MyMod: Enabled (on)". + static std::map g_restart_changes; + + // Baseline serialized value (as of this menu session's open) for each restart-required setting + // that was touched, keyed identically to g_restart_changes. Used to drop a setting from the + // restart list when it is changed back to its baseline (no net change -> no restart needed). + static std::map g_restart_baselines; + + // The native restart message box's (only) button; clicking it closes the game (restart). + static GUIComponent* g_restart_confirm_button = nullptr; + + // True once the restart prompt has been shown this menu session (so closing again proceeds). + static bool g_restart_prompt_shown = false; + // Which view the Mods panel is currently showing, plus a deferred navigation request // that a click sets and the Update hook applies at a safe point (outside input/click // iteration, where mutating the component vectors is safe). @@ -793,6 +852,86 @@ namespace big::mod_settings g_nav_pending = true; } + // Replaces each ASCII space with a non-breaking space (U+00A0, UTF-8 0xC2 0xA0). The message + // textbox auto-wraps at breakable spaces (computed at the template font size, before our font + // scaling), which would split a single logical line; non-breaking spaces keep it on one line. + static std::string to_non_breaking(const std::string& text) + { + std::string out; + out.reserve(text.size() + text.size() / 4); + for (char c : text) + { + if (c == ' ') + { + out += "\xC2\xA0"; + } + else + { + out += c; + } + } + return out; + } + + // Composite key ("\0
\0") uniquely identifying a config entry across mods. + static std::string restart_change_key(toml_v2::config_file::config_entry_base* entry, const std::string& stem) + { + return stem + '\0' + entry->m_definition.m_section + '\0' + entry->m_definition.m_key; + } + + // Captures a restart-required setting's baseline (its value as of this menu session's open) + // BEFORE it is first modified, so a later change back to this value can be recognised as "no + // net change". Called just before the value is written. No-op for non-restart-required settings + // and after the first capture for a given setting. + static void capture_restart_baseline(toml_v2::config_file::config_entry_base* entry) + { + if (!entry || !entry->m_config_file) + { + return; + } + const std::string& stem = entry->m_config_file->m_config_file_stem_as_str; + if (!setting_requires_restart(stem, entry->m_definition.m_section, entry->m_definition.m_key)) + { + return; + } + const std::string key = restart_change_key(entry, stem); + g_restart_baselines.try_emplace(key, entry->get_serialized_value()); + } + + // Records or clears a restart-required setting change after the value has been written. If the + // new value equals the session baseline (e.g. a toggle flipped and flipped back, or a number + // re-typed to its original), nothing actually changed, so the setting is dropped from the + // restart list; otherwise it is listed. `new_value_display` is the value shown in the popup. + // g_restart_required stays set as long as any real change remains. + static void note_change_if_restart_required(toml_v2::config_file::config_entry_base* entry, const std::string& new_value_display) + { + if (!entry || !entry->m_config_file) + { + return; + } + const std::string& stem = entry->m_config_file->m_config_file_stem_as_str; + if (!setting_requires_restart(stem, entry->m_definition.m_section, entry->m_definition.m_key)) + { + return; + } + const std::string key = restart_change_key(entry, stem); + + const auto baseline = g_restart_baselines.find(key); + if (baseline != g_restart_baselines.end() && entry->get_serialized_value() == baseline->second) + { + // Reverted to the session baseline: no net change, so it no longer needs a restart. + g_restart_changes.erase(key); + } + else + { + // Keep each mod/setting/value entry on one line (see to_non_breaking). + const std::string line = display_name_from_stem(stem) + ": " + key_to_display(entry->m_definition.m_key) + " (" + new_value_display + ")"; + g_restart_changes[key] = to_non_breaking(line); + } + + g_restart_required = !g_restart_changes.empty(); + } + // Commits or cancels a pending edit. Called from the HandleInput hook so it runs on the // same frame the triggering key/click is swallowed (HandleInput returns true that // frame), which prevents a submitting mouse click from also activating the row it lands @@ -803,9 +942,16 @@ namespace big::mod_settings { if (g_edit_entry) { + // Capture the session baseline before the first write so a later revert to it + // is recognised as "no net change". + capture_restart_baseline(g_edit_entry); + // set_serialized_value validates (e.g. numbers) and only stores/saves a // valid value, so bad input for a number simply keeps the old value. g_edit_entry->set_serialized_value(g_edit_buffer); + + // If the author declared this setting restart-required, flag/clear the restart. + note_change_if_restart_required(g_edit_entry, g_edit_entry->get_serialized_value()); } exit_edit_mode(); request_settings_rebuild(); @@ -1016,6 +1162,107 @@ namespace big::mod_settings build_panel(screen, instant); } + // Builds the restart-popup body text from the changes collected this session. Blank lines are a + // single non-breaking space (U+00A0): ShowText trims ASCII-whitespace-only lines (so "\n\n" and + // "\n \n" collapse) but keeps an nbsp line. A sacrificial trailing nbsp line is appended because + // the formatter also trims the LAST whitespace-only line, which would otherwise merge the blank + // before the outro into it. Intro/outro and each change entry are non-breaking so the + // width-greedy formatter keeps each on one line. + static std::string build_restart_message() + { + const std::string blank = "\xC2\xA0"; // nbsp: a whitespace line ShowText will not trim + + std::string msg = to_non_breaking("A restart is required because you changed these settings:"); + msg += "\n" + blank + "\n"; + for (const auto& change : g_restart_changes) + { + msg += change.second; + msg += "\n"; + } + msg += blank + "\n"; + msg += to_non_breaking("The game will now close. Please restart it to apply the changes."); + msg += "\n" + blank; // sacrificial trailing blank so the one above the outro survives + return msg; + } + + // Builds an empty EASTL SSO string (24-byte layout) in `buf` (>=24 bytes). Passed to the + // dialog ctor (message) and AddScreen (name); the real message is applied afterwards via + // ShowText. Layout: bytes[0..]=chars, byte[23]=remaining-capacity marker (23 - length). + static void make_eastl_sso(char* buf, const char* text) + { + std::size_t n = std::strlen(text); + if (n > 22) + { + n = 22; + } + std::memset(buf, 0, 24); + std::memcpy(buf, text, n); + buf[23] = static_cast(23 - n); + } + + // Shows the native single-button "restart required" message box (sgg::MessageDialog, the same + // box the game uses in the main menu for save/file errors). `message` is shown as the body + // text. Its only button closes the game (handled in the OnClicked hook) - a restart-required + // change must not be cancellable, since cancelling would have to undo the change. Returns true + // if the native dialog was shown; otherwise falls back to a MessageBox (OK closes the game). + static bool show_restart_dialog(void* screen_manager, const std::string& message) + { + if (screen_manager && g_message_dialog_ctor && g_add_screen) + { + void* dialog = _aligned_malloc(message_dialog_size, 8); + if (dialog) + { + std::memset(dialog, 0, message_dialog_size); + + // The ctor builds every component (single button + text) and loads + // GUI/MessageDialog.sjson. Pass an empty message; the real (multi-line) text is + // applied below via ShowText so it need not be an eastl heap string. + char empty_message[24]; + make_eastl_sso(empty_message, ""); + g_message_dialog_ctor(dialog, screen_manager, empty_message); + + auto* bytes = reinterpret_cast(dialog); + + // Ensure the dialog is visible and modal over the options screen. + bytes[screen_removed_offset] = 0; + bytes[screen_visible_offset] = 1; + bytes[screen_block_input_offset] = 1; + + // Set the title + body (raw text; the body carries the restart-causing settings). + if (g_show_text) + { + if (auto* title_box = *reinterpret_cast(bytes + dialog_title_offset)) + { + g_show_text(title_box, "Restart Required"); + } + if (auto* message_box = *reinterpret_cast(bytes + dialog_message_offset)) + { + // Shrink the body font: the sjson template renders at size 26; scale the + // live font handle's size ratios down before ShowText lays out the lines + // (the def's mFontSize is ignored once the template is loaded). + char* handle = reinterpret_cast(message_box) + textbox_font_handle_offset; + *reinterpret_cast(handle + font_handle_size_ratio_offset) *= restart_message_font_scale; + *reinterpret_cast(handle + font_handle_eng_size_ratio_offset) *= restart_message_font_scale; + g_show_text(message_box, message.c_str()); + } + } + + // Capture the confirm button so the OnClicked hook closes the game on press. + g_restart_confirm_button = *reinterpret_cast(bytes + dialog_confirm_button_offset); + + // Add at the END of the screen list so it draws on top of the options menu. + char empty_name[24]; + make_eastl_sso(empty_name, ""); + g_add_screen(screen_manager, dialog, true, empty_name); + return true; + } + } + + MessageBoxW(nullptr, L"A changed mod setting requires a restart. The game will now close - please restart it.", L"Hell2Modding - Restart Required", MB_OK | MB_ICONWARNING | MB_SETFOREGROUND); + TerminateProcess(GetCurrentProcess(), 0); + return false; + } + static void* hook_MiscSettingsScreen_ctor(void* self, void* screen_manager, void* opened_from, void* profile_name) { // Reset state BEFORE running the original ctor: the original ctor immediately shows @@ -1024,7 +1271,12 @@ namespace big::mod_settings g_rows.clear(); g_view = View::mod_list; g_view_stem.clear(); - g_nav_pending = false; + g_nav_pending = false; + g_restart_required = false; + g_restart_prompt_shown = false; + g_restart_confirm_button = nullptr; + g_restart_changes.clear(); + g_restart_baselines.clear(); exit_edit_mode(); // The engine constructor returns `this`; forward it unchanged. @@ -1071,6 +1323,13 @@ namespace big::mod_settings // vectors is safe (this runs mid input iteration). static bool hook_GUIComponentButton_OnClicked(GUIComponent* self, std::uint64_t location) { + // Clicking the restart message box's button closes the game (forced restart). + if (self && self == g_restart_confirm_button) + { + big::g_hooking->get_original()(self, location); + TerminateProcess(GetCurrentProcess(), 0); + } + RowKind kind = RowKind::mod_entry; std::string stem; toml_v2::config_file::config_entry_base* entry = nullptr; @@ -1117,10 +1376,18 @@ namespace big::mod_settings // Boolean settings toggle in place; other types open a freetext editor. if (entry && entry->type() == typeid(bool)) { + // Capture the session baseline before the first write so a later revert to + // it (toggling off then on again) is recognised as "no net change". + capture_restart_baseline(entry); + const bool new_value = !entry->get_value_base(); entry->set_value_base(new_value); set_toggle_graphic(self, new_value); + // If the author declared this setting restart-required, flag/clear the + // restart and record the change so the popup can list what forced it. + note_change_if_restart_required(entry, new_value ? "on" : "off"); + // Toggling the mod's master "enabled" switch changes which other rows // are greyed out, so rebuild the settings view on the next Update. if (is_enabled_toggle) @@ -1214,9 +1481,32 @@ namespace big::mod_settings commit_or_cancel_edit(); return true; } + return big::g_hooking->get_original()(self, input, x); } + // Close funnel for the options screen: every way the user dismisses it (Escape key, controller + // B, or clicking the on-screen "Exit" button) converges here (MiscSettingsScreen::ExitScreen, + // vtable slot 7), before any fade/teardown and while mScreenManager is valid. If a restart is + // required, show the native message box and DO NOT run the original (veto the close): the box + // is modal over the still-open options screen and its button closes the game. A restart-required + // change must not be cancellable (that would require undoing the change), so the restart is + // forced. If the native dialog cannot be shown, the MessageBox fallback closes the game anyway. + static void hook_MiscSettingsScreen_ExitScreen(void* self) + { + if (g_restart_required && !g_restart_prompt_shown) + { + g_restart_prompt_shown = true; + void* screen_manager = *reinterpret_cast(reinterpret_cast(self) + screen_manager_offset); + if (show_restart_dialog(screen_manager, build_restart_message())) + { + return; + } + } + + big::g_hooking->get_original()(self); + } + void register_hooks() { const auto ctor = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::MiscSettingsScreen"]; @@ -1243,6 +1533,19 @@ namespace big::mod_settings g_push_back = big::hades2_symbol_to_address["eastl::vector::push_back"].as_func(); + // ShowText has a single overload, so it resolves by name. + g_show_text = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::ShowText"].as_func(); + + // MessageDialog::MessageDialog and ScreenManager::AddScreen are overloaded, so the PDB + // symbol map cannot pick the wanted overload by name; resolve their DIA-validated RVAs + // off the button-ctor anchor (same approach as the g_push_back fallback below). + if (const auto anchor = big::hades2_symbol_to_address["sgg::GUIComponentButton::GUIComponentButton"]) + { + const auto base = anchor.as() - anchor_rva; + g_message_dialog_ctor = reinterpret_cast(base + message_dialog_ctor_rva); + g_add_screen = reinterpret_cast(base + add_screen_rva); + } + if (!g_push_back) { const auto anchor = big::hades2_symbol_to_address["sgg::GUIComponentButton::GUIComponentButton"]; @@ -1300,5 +1603,18 @@ namespace big::mod_settings LOG(WARNING) << "[mod_settings] sgg::MiscSettingsScreen::HandleInput not found; freetext editing may not " "block menu nav"; } + + // Every close path (Escape key, controller B, clicking the on-screen Exit button) funnels + // through ExitScreen, so this is where the restart-required prompt is triggered. + const auto exit_screen = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ExitScreen"]; + if (exit_screen) + { + static auto exit_screen_hook = hooking::detour_hook_helper::add_queue("sgg::MiscSettingsScreen::ExitScreen", exit_screen); + } + else + { + LOG(WARNING) << "[mod_settings] sgg::MiscSettingsScreen::ExitScreen not found; the restart-required prompt " + "will not appear"; + } } } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 135d8d9..509a405 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -1,7 +1,14 @@ #pragma once +#include + namespace big::mod_settings { void register_hooks(); void bind_config_api(sol::state_view& state, sol::table& lua_ext); + + // True if a mod author declared this setting as requiring a game restart to take effect + // (via `restart_required = true` in the setting's config.lua description). Populated by + // rom.mod_settings.load; consulted by the settings menu when a value changes. + bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key); } // namespace big::mod_settings From dd506a698259833555dc28db6a73ceb012565080 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:15:09 +0100 Subject: [PATCH 005/100] Add hover descriptions, metadata registry, native row colour and value alignment to the Mods settings tab --- src/hades2/mod_settings/config_api.cpp | 161 +++++++++++++--- src/hades2/mod_settings/mod_settings.cpp | 225 +++++++++++++++++++---- src/hades2/mod_settings/mod_settings.hpp | 36 ++++ 3 files changed, 362 insertions(+), 60 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 3de80d0..7c1e4fb 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -1,5 +1,6 @@ #include "mod_settings.hpp" +#include #include #include #include @@ -17,13 +18,14 @@ using namespace al; namespace big::mod_settings { // Author-declared per-setting metadata registry, populated from each mod's config.lua by - // rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Currently records only - // whether a setting requires a game restart to take effect (set via `restart_required = true` - // in a setting's config.lua description table); extensible to context/visibility later. This + // rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Holds the widget type, + // numeric bounds, enum options, display name, ordering, and the restart-required flag that the + // settings menu reads to pick and drive a widget. Only settings whose config.lua description is + // a rich table are registered; the rest fall back to type-based rendering. The restart flag // replaces the old sjson-hook auto-detection, which could not see a mod that starts disabled // (it registers no hooks until enabled), and never covered restarts needed for other reasons. static std::mutex g_metadata_mutex; - static std::map g_restart_required_settings; + static std::map g_setting_metadata; static std::string metadata_key(const std::string& guid, const std::string& section, const std::string& key) { @@ -42,17 +44,28 @@ namespace big::mod_settings static void clear_metadata_for(const std::string& guid) { const std::string prefix = guid + '\0'; - for (auto it = g_restart_required_settings.begin(); it != g_restart_required_settings.end();) + for (auto it = g_setting_metadata.begin(); it != g_setting_metadata.end();) { - it = (it->first.rfind(prefix, 0) == 0) ? g_restart_required_settings.erase(it) : std::next(it); + it = (it->first.rfind(prefix, 0) == 0) ? g_setting_metadata.erase(it) : std::next(it); } } bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key) { std::scoped_lock lock(g_metadata_mutex); - const auto it = g_restart_required_settings.find(metadata_key(guid, section, key)); - return it != g_restart_required_settings.end() && it->second; + const auto it = g_setting_metadata.find(metadata_key(guid, section, key)); + return it != g_setting_metadata.end() && it->second.restart_required; + } + + std::optional get_setting_metadata(const std::string& guid, const std::string& section, const std::string& key) + { + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_setting_metadata.find(metadata_key(guid, section, key)); + if (it == g_setting_metadata.end()) + { + return std::nullopt; + } + return it->second; } // Extracts a description string from a config.lua description value, which may be a plain string @@ -91,6 +104,102 @@ namespace big::mod_settings return flag.is() && flag.as(); } + // Serializes a Lua enum-option value (bool/number/string) into the exact string form a config + // entry serializes to, so the menu can match an option against the stored value. Numbers use + // the same locale-invariant std::format the toml converter uses, and every config number is + // stored as a double. + static std::string serialize_option(const sol::object& v) + { + switch (v.get_type()) + { + case sol::type::string: return v.as(); + case sol::type::boolean: return v.as() ? "true" : "false"; + case sol::type::number: return std::format("{}", v.as()); + default: return ""; + } + } + + // Reads the array part of a Lua list table (ipairs order) applying `transform` to each element. + template + static void read_list(const sol::object& obj, std::vector& out, Transform transform) + { + if (!obj.is()) + { + return; + } + sol::table t = obj.as(); + for (std::size_t i = 1; i <= t.size(); ++i) + { + out.push_back(transform(t[i])); + } + } + + // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. + // Missing fields keep their defaults. The widget kind is not stored: the menu derives it from + // the config value's type plus the presence of `values` (enum), so authors never declare a + // `type`. Author-only inputs that cannot be inferred (name, bounds, enum options/labels, + // order, hidden, restart) are what this captures. + static setting_metadata extract_metadata(const sol::table& desc) + { + setting_metadata m; + m.description = describe(desc); + + sol::object name = desc["name"]; + if (name.get_type() == sol::type::string) + { + m.name = name.as(); + } + + sol::object min_field = desc["min"]; + if (min_field.get_type() == sol::type::number) + { + m.has_min = true; + m.min = min_field.as(); + } + sol::object max_field = desc["max"]; + if (max_field.get_type() == sol::type::number) + { + m.has_max = true; + m.max = max_field.as(); + } + sol::object step_field = desc["step"]; + if (step_field.get_type() == sol::type::number) + { + m.has_step = true; + m.step = step_field.as(); + } + + read_list(desc["values"], + m.values, + [](const sol::object& v) + { + return serialize_option(v); + }); + read_list(desc["labels"], + m.labels, + [](const sol::object& v) + { + return v.get_type() == sol::type::string ? v.as() : serialize_option(v); + }); + + sol::object order_field = desc["order"]; + if (order_field.get_type() == sol::type::number) + { + m.has_order = true; + m.order = order_field.as(); + } + + sol::object hidden_field = desc["hidden"]; + if (hidden_field.is()) + { + m.hidden = hidden_field.as(); + } + + m.restart_required = description_requires_restart(desc); + + return m; + } + // Finds the config entry for (section, key), or nullptr. m_entries is keyed by config_definition, // so this is a direct map lookup. static toml_v2::config_file::config_entry_base* find_entry(toml_v2::config_file* cf, const std::string& section, const std::string& key) @@ -179,12 +288,21 @@ namespace big::mod_settings } }; + // A setting's extracted metadata together with the section/key it belongs to, collected while + // walking config.lua and then folded into the registry. + struct collected_metadata + { + std::string section; + std::string key; + setting_metadata meta; + }; + // Recursively binds a config.lua `defaults` table into `cf` under `section`, forwarding each - // leaf's description. Nested tables become sub-sections ("section.key"). Leaf keys whose - // description declares restart_required are appended to `restart_out` as (section, key) pairs. + // leaf's description. Nested tables become sub-sections ("section.key"). Each flat leaf whose + // description is a rich table has its metadata extracted into `meta_out` (keyed by section+key). // config_file::bind adopts a value already saved in the .cfg, preserving user edits, and binds // under section "config" so the .cfg stays byte-compatible with what SGG_Modding-Chalk wrote. - static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector>& restart_out) + static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector& meta_out) { sol::table desc_tbl; const bool has_desc = desc_obj.is(); @@ -211,7 +329,7 @@ namespace big::mod_settings switch (vt) { case sol::type::table: - bind_defaults(cf, value_obj.as(), desc, section + "." + key, restart_out); + bind_defaults(cf, value_obj.as(), desc, section + "." + key, meta_out); break; case sol::type::boolean: cf->bind(section, key, value_obj.as(), describe(desc)); break; case sol::type::number: cf->bind(section, key, value_obj.as(), describe(desc)); break; @@ -219,9 +337,11 @@ namespace big::mod_settings default: continue; } - if (vt != sol::type::table && description_requires_restart(desc)) + // Only a rich description table carries metadata; a nested value is a sub-section (its + // table holds child descriptions, not this key's metadata) and is handled by recursion. + if (vt != sol::type::table && desc.is()) { - restart_out.emplace_back(section, key); + meta_out.push_back({section, key, extract_metadata(desc.as())}); } } } @@ -283,22 +403,21 @@ namespace big::mod_settings sol::object descriptions = cfg_result[1]; // Bind the defaults into the config_file (section root "config", matching Chalk) and collect - // the author-declared restart-required settings, then persist the file. - std::vector> restart_settings; + // each rich setting's metadata, then persist the file. + std::vector collected; if (defaults.is()) { - bind_defaults(cf.get(), defaults.as(), descriptions, "config", restart_settings); + bind_defaults(cf.get(), defaults.as(), descriptions, "config", collected); } cf->save(); - // Register this mod's restart-required settings into the metadata registry (replacing any - // from a previous load of the same mod). + // Register this mod's setting metadata (replacing any from a previous load of the same mod). { std::scoped_lock lock(g_metadata_mutex); clear_metadata_for(guid); - for (const auto& [section, key] : restart_settings) + for (auto& cm : collected) { - g_restart_required_settings[metadata_key(guid, section, key)] = true; + g_setting_metadata[metadata_key(guid, cm.section, cm.key)] = std::move(cm.meta); } } diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index e9b2eb9..0d70c9a 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -122,6 +122,7 @@ namespace big::mod_settings using message_dialog_ctor_fn = void* (*)(void* self, void* screen_manager, void* eastl_message); using add_screen_fn = void (*)(void* screen_manager, void* screen, bool add_at_end, void* eastl_name); using show_text_fn = void (*)(void* text_box, const char* text); + using get_lines_fn = void* (*)(void* text_box); // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -147,6 +148,7 @@ namespace big::mod_settings static message_dialog_ctor_fn g_message_dialog_ctor = nullptr; static add_screen_fn g_add_screen = nullptr; static show_text_fn g_show_text = nullptr; + static get_lines_fn g_get_lines = nullptr; // sgg::KeyboardButtonId values used for edit confirm/cancel (validated in the PDB). static constexpr int key_escape = 0; @@ -162,12 +164,12 @@ namespace big::mod_settings // ScreenCenterOffsetY, and X = the row's own location. Rows mirror the key-rebind // ControlButton layout: the component is anchored to the right pane and its text is // left-justified via a negative text offset, matching the native option-name column. - static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton - static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column - static constexpr float value_text_offset_x = 55.0f; // right-justify the value; aligns with the toggle indicator column - static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) - static constexpr float row_base_y = 315.0f; // first row's Y (aligns with the tab column) - static constexpr float row_pitch = 54.0f; // vertical distance between rows + static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton + static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column + static constexpr float value_text_offset_x = 15.0f; // right-justify the value; right edge aligns with the toggle's + static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) + static constexpr float row_base_y = 315.0f; // first row's Y (aligns with the tab column) + static constexpr float row_pitch = 54.0f; // vertical distance between rows static constexpr std::uint32_t rows_per_page = 8; // Edit-cursor blink half-period (ms): the "|" shows for this long, then hides. @@ -196,9 +198,21 @@ namespace big::mod_settings bool disabled = false; // greyed & non-interactable (mod disabled) bool is_enabled_toggle = false; // the mod's master "enabled" toggle + // Author-provided description shown at the bottom of the screen while this row is + // highlighted (setting rows only; empty for navigation rows). + std::string description; + // Right-column value display for a non-bool setting row (paired with `component`, the // left-column key). Not in mOptions; positioned to follow `component` each frame. GUIComponent* value_component = nullptr; + + // Numeric stepper (bounded number setting: metadata has both min and max). Left/right + // adjusts the value by `stepper_step`, clamped to [stepper_min, stepper_max]; a mouse + // click increments and wraps. When false, a numeric setting uses the freetext editor. + bool is_stepper = false; + double stepper_min = 0.0; + double stepper_max = 0.0; + double stepper_step = 1.0; }; static std::vector g_rows; @@ -362,15 +376,19 @@ namespace big::mod_settings *reinterpret_cast(def + def_sel_text_blue) = grey; } - // Forces a row's normal text colour to full white so plain-text (key/value) rows read as - // bright/editable, matching the toggle rows. The selected colour is left as the template's - // so hover still highlights. Must run before SetupComponent to reach the text box. - static void set_def_text_white(GUIComponent* row) + // Sets a row's normal text colour to the native settings-option grey (0.55) used by the + // game's own OptionToggleButton / OptionNumBox rows, so plain-text (key/value) rows built on + // the CategoryOptionsButton template (whose own text is a darker 0.35) match the toggle rows + // instead of reading as brighter full white. The selected colour is left as the template's + // (the same green highlight both templates use) so hover still highlights. Must run before + // SetupComponent to reach the text box. + static void set_def_text_normal(GUIComponent* row) { char* def = reinterpret_cast(row) + component_def_offset; - *reinterpret_cast(def + def_text_red) = 1.0f; - *reinterpret_cast(def + def_text_green) = 1.0f; - *reinterpret_cast(def + def_text_blue) = 1.0f; + constexpr float option_grey = 0.55f; // matches MiscSettingsScreen.sjson option rows + *reinterpret_cast(def + def_text_red) = option_grey; + *reinterpret_cast(def + def_text_green) = option_grey; + *reinterpret_cast(def + def_text_blue) = option_grey; } // A plain left-justified text row (mod names, Back, and non-toggle settings). Applies a @@ -409,7 +427,7 @@ namespace big::mod_settings } else { - set_def_text_white(row); + set_def_text_normal(row); } if (g_setup_component) @@ -591,7 +609,7 @@ namespace big::mod_settings } else { - set_def_text_white(row); + set_def_text_normal(row); } if (g_setup_component) @@ -984,6 +1002,48 @@ namespace big::mod_settings return big::string::to_lower(key) == "enabled"; } + // Adjusts a bounded numeric stepper row by `direction` steps (+1 = increment, -1 = decrement). + // `wrap` cycles past a bound to the opposite one (used for a mouse click, so the value can be + // reached without arrow keys); otherwise the value is clamped to [min, max] (used for + // left/right, matching native number options). Writes the new value (which auto-saves and + // fires on_setting_changed), records any restart-required change, and refreshes the value + // label in place - no full rebuild, so rapid stepping stays smooth. + static void step_stepper_row(const PanelRow& row, int direction, bool wrap) + { + auto* entry = row.entry; + if (!entry || entry->type() != typeid(double) || row.disabled) + { + return; + } + + const double step = (row.stepper_step != 0.0) ? row.stepper_step : 1.0; + const double cur = entry->get_value_base(); + double next = cur + direction * step; + + if (next > row.stepper_max) + { + next = wrap ? row.stepper_min : row.stepper_max; + } + else if (next < row.stepper_min) + { + next = wrap ? row.stepper_max : row.stepper_min; + } + + if (next == cur) + { + return; // already at the clamped bound; nothing changed + } + + capture_restart_baseline(entry); + entry->set_value_base(next); // auto-saves via on_setting_changed + note_change_if_restart_required(entry, entry->get_serialized_value()); + + if (row.value_component && g_set_label) + { + g_set_label(row.value_component, entry->get_serialized_value().c_str()); + } + } + // Level 2: a Back row followed by one row per config entry belonging to `stem`. Boolean // entries render as native toggle rows; other types render as a left-aligned key with a // right-aligned, freetext-editable value (two components). A boolean "enabled" entry (if @@ -1034,16 +1094,24 @@ namespace big::mod_settings const bool is_enabled_row = (entry == enabled_entry); const bool disabled = !is_enabled_row && !mod_enabled; + // Author metadata (if any) can rename the row, hide it, and (later) pick its widget. + const auto meta = get_setting_metadata(stem, entry->m_definition.m_section, entry->m_definition.m_key); + if (meta && meta->hidden) + { + continue; + } + const std::string label = (meta && !meta->name.empty()) ? meta->name : key_to_display(key); + GUIComponent* row = nullptr; GUIComponent* value = nullptr; if (entry->type() == typeid(bool)) { - row = make_toggle_row(screen, key_to_display(key).c_str(), entry->get_value_base(), disabled); + row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), disabled); } else { // Left-aligned key + right-aligned value (two components), like a keybind row. - row = make_text_row(screen, key_to_display(key).c_str(), disabled); + row = make_text_row(screen, label.c_str(), disabled); if (row) { const std::string v = entry ? entry->get_serialized_value() : std::string{}; @@ -1057,6 +1125,19 @@ namespace big::mod_settings pr.disabled = disabled; pr.is_enabled_toggle = is_enabled_row; pr.value_component = value; + // Prefer the author's metadata description; fall back to the .cfg comment text. + pr.description = (meta && !meta->description.empty()) ? meta->description : entry->m_description.m_description; + + // A numeric setting with author-declared min AND max becomes a bounded stepper + // (left/right adjusts by step); otherwise numbers stay freetext-editable. + if (entry->type() == typeid(double) && meta && meta->has_min && meta->has_max) + { + pr.is_stepper = true; + pr.stepper_min = meta->min; + pr.stepper_max = meta->max; + pr.stepper_step = meta->has_step ? meta->step : 1.0; + } + g_rows.push_back(pr); } } @@ -1083,8 +1164,71 @@ namespace big::mod_settings } } + // The component whose description was last written to the description box, so the box is only + // updated when the highlighted row changes (not every frame). Reset when the panel rebuilds. + static GUIComponent* g_last_description_component = nullptr; + + // Shows the highlighted row's author description in the screen's native description box + // (MiscSettingsScreen::mDescriptionBox @ 0x460). The highlighted component is the mouse-over + // one (mouse) or the selected one (keyboard/controller); if it is one of our rows, its + // description is shown as raw text, otherwise the box is cleared. + static void sync_description_box(MiscSettingsScreen* screen) + { + if (!g_show_text || !screen->m_description_box) + { + return; + } + auto* box = screen->m_description_box; + + GUIComponent* active = reinterpret_cast(screen)->m_mouse_over_component; + if (!active) + { + active = reinterpret_cast(screen)->m_selected_component; + } + + // Resolve the highlighted row's description (cheap linear scan over the few visible rows). + const std::string* description = nullptr; + if (active) + { + for (const auto& row : g_rows) + { + if (row.component == active) + { + description = &row.description; + break; + } + } + } + const bool show = description && !description->empty(); + + // Rebuild the text only when the highlighted row changes (ShowText re-lays out the lines). + if (active != g_last_description_component) + { + g_last_description_component = active; + g_show_text(box, show ? description->c_str() : ""); + + // ShowText only marks the lines dirty; the layout (and text height, which the box's + // justification uses to place the text) is otherwise recomputed lazily at draw time, + // so the first visible frame would render at a stale position and visibly jump. Force + // the line rebuild now so the first shown frame is already laid out. + if (show && g_get_lines) + { + g_get_lines(box); + } + } + + // Re-apply the fade every frame: the native Update runs before this and re-hides the box on + // the Mods tab (it does not use mDescriptionBox here), so a one-time set would fade back out. + box->m_fade_opacity = show ? 1.0f : 0.0f; + box->m_fade_target = show ? 1.0f : 0.0f; + } + static void build_panel(MiscSettingsScreen* screen, bool instant = false) { + // A rebuild frees and recreates the row components, so the cached highlighted-row pointer + // is no longer meaningful; force the description box to refresh next frame. + g_last_description_component = nullptr; + // Preserve the current scroll offset across an in-place refresh (same view/mod, e.g. // after committing a setting edit or toggling "enabled") so confirming a setting on a // lower page does not jump back to the top. A real view change (instant == false) @@ -1277,6 +1421,7 @@ namespace big::mod_settings g_restart_confirm_button = nullptr; g_restart_changes.clear(); g_restart_baselines.clear(); + g_last_description_component = nullptr; exit_edit_mode(); // The engine constructor returns `this`; forward it unchanged. @@ -1330,13 +1475,8 @@ namespace big::mod_settings TerminateProcess(GetCurrentProcess(), 0); } - RowKind kind = RowKind::mod_entry; - std::string stem; - toml_v2::config_file::config_entry_base* entry = nullptr; - GUIComponent* value_component = nullptr; - bool matched = false; - bool disabled = false; - bool is_enabled_toggle = false; + PanelRow matched_row; + bool matched = false; if (self) { @@ -1344,13 +1484,8 @@ namespace big::mod_settings { if (row.component == self) { - kind = row.kind; - stem = row.stem; - entry = row.entry; - value_component = row.value_component; - disabled = row.disabled; - is_enabled_toggle = row.is_enabled_toggle; - matched = true; + matched_row = row; + matched = true; break; } } @@ -1358,13 +1493,13 @@ namespace big::mod_settings const bool result = big::g_hooking->get_original()(self, location); - if (matched && !disabled) + if (matched && !matched_row.disabled) { - switch (kind) + switch (matched_row.kind) { case RowKind::mod_entry: g_pending_view = View::mod_settings; - g_pending_stem = stem; + g_pending_stem = matched_row.stem; g_nav_pending = true; break; case RowKind::back: @@ -1373,7 +1508,9 @@ namespace big::mod_settings g_nav_pending = true; break; case RowKind::setting: - // Boolean settings toggle in place; other types open a freetext editor. + { + auto* entry = matched_row.entry; + // Boolean settings toggle in place; bounded numbers step; other types edit. if (entry && entry->type() == typeid(bool)) { // Capture the session baseline before the first write so a later revert to @@ -1390,18 +1527,25 @@ namespace big::mod_settings // Toggling the mod's master "enabled" switch changes which other rows // are greyed out, so rebuild the settings view on the next Update. - if (is_enabled_toggle) + if (matched_row.is_enabled_toggle) { g_pending_view = View::mod_settings; - g_pending_stem = stem; + g_pending_stem = matched_row.stem; g_nav_pending = true; } } + else if (matched_row.is_stepper) + { + // A click on a bounded number steps it up, wrapping past the max back to + // the min so mouse users can reach every value without arrow keys. + step_stepper_row(matched_row, +1, true); + } else if (entry) { - enter_edit_mode(value_component, entry); + enter_edit_mode(matched_row.value_component, entry); } break; + } case RowKind::action: // TODO: dispatch the action row's callback. break; @@ -1447,10 +1591,12 @@ namespace big::mod_settings void* result = big::g_hooking->get_original()(self, dt, input); // The original just laid out the key rows for this frame; mirror the value columns - // onto them so the right column tracks scrolling and fade. + // onto them so the right column tracks scrolling and fade, and show the highlighted + // row's description in the native description box. if (on_mods_tab) { sync_value_columns(); + sync_description_box(screen); } return result; @@ -1535,6 +1681,7 @@ namespace big::mod_settings // ShowText has a single overload, so it resolves by name. g_show_text = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::ShowText"].as_func(); + g_get_lines = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GetLines"].as_func(); // MessageDialog::MessageDialog and ScreenManager::AddScreen are overloaded, so the PDB // symbol map cannot pick the wanted overload by name; resolve their DIA-validated RVAs diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 509a405..5da08ee 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -1,14 +1,50 @@ #pragma once +#include #include +#include namespace big::mod_settings { void register_hooks(); void bind_config_api(sol::state_view& state, sol::table& lua_ext); + // Author-declared metadata for a single setting, extracted from its config.lua description + // table by rom.mod_settings.load and consulted by the settings menu. Only settings whose + // description is a rich table have an entry; the rest fall back to type-based rendering. Every + // field is an author-only input that cannot be inferred from the config value (the widget kind + // itself IS inferred from the value + `values`, so there is deliberately no `type` field here). + // All fields are optional (see the has_* flags). + struct setting_metadata + { + std::string name; // display-name override (empty -> prettified key) + std::string description; // same text written to the .cfg comment + + bool has_min = false; + double min = 0.0; + bool has_max = false; + double max = 0.0; + bool has_step = false; + double step = 0.0; + + // Enum options: serialized option values and parallel display labels (labels default to + // the values when omitted). Serialized form matches the config entry's serialization. + std::vector values; + std::vector labels; + + bool has_order = false; + double order = 0.0; // author-declared sort key (lower first); unset -> map order + + bool hidden = false; // author asked to omit this row entirely + bool restart_required = false; // change only takes effect after a game restart + }; + // True if a mod author declared this setting as requiring a game restart to take effect // (via `restart_required = true` in the setting's config.lua description). Populated by // rom.mod_settings.load; consulted by the settings menu when a value changes. bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key); + + // Returns the author-declared metadata for a setting, or std::nullopt when the setting has no + // rich metadata table (in which case the menu renders it with type-based defaults). + std::optional get_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); } // namespace big::mod_settings From 5f3dabd9b68fee75e1c1732f4885f09c254d6821 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:19:33 +0100 Subject: [PATCH 006/100] Add native number-box stepper rows for bounded numeric settings in the Mods tab --- src/hades2/mod_settings/mod_settings.cpp | 293 +++++++++++++++++------ 1 file changed, 222 insertions(+), 71 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 0d70c9a..0bf106e 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -3,6 +3,7 @@ #include "sgg_gui.hpp" #include +#include #include #include #include @@ -106,6 +107,27 @@ namespace big::mod_settings static constexpr std::uintptr_t anchor_rva = 0x11'5C'70; // sgg::GUIComponentButton::GUIComponentButton static constexpr std::uintptr_t message_dialog_ctor_rva = 0x16'EE'60; // sgg::MessageDialog::MessageDialog(this,sm,eastl::string*) static constexpr std::uintptr_t add_screen_rva = 0x14'7D'D0; // sgg::ScreenManager::AddScreen(this,screen,bool,eastl::string*) + // tf_new_internal: the game's own factory that + // allocates a GUIComponentNumBox, sets its vtable and builds its 5 sub-components (box graphic, + // label, value text, left/right arrows). Template instantiation, so resolved by RVA off the anchor. + static constexpr std::uintptr_t numbox_factory_rva = 0x17'A5'30; + + // sgg::GUIComponentNumBox field offsets (DIA-validated on the current Ship build). sizeof 0x5D0; + // derives directly from GUIComponent (not GUIComponentButton). + static constexpr std::size_t numbox_value_offset = 0x5'40; // mNumberValue (float) + static constexpr std::size_t numbox_step_offset = 0x5'44; // mNumberStepValue (float) + static constexpr std::size_t numbox_min_offset = 0x5'48; // mNumberMin (float) + static constexpr std::size_t numbox_max_offset = 0x5'4C; // mNumberMax (float) + static constexpr std::size_t numbox_is_integer_offset = 0x5'50; // mIsInteger (bool: discrete + integer display) + static constexpr std::size_t numbox_disable_input_offset = 0x5'63; // mDisableInput (bool: HandleInput early-out) + static constexpr std::size_t numbox_value_text_offset = 0x5'B0; // mValueTextBox (GUIComponentTextBox*) + static constexpr std::size_t numbox_left_arrow_offset = 0x5'98; // mLeftArrow (GUIComponentAnimation*) + static constexpr std::size_t numbox_right_arrow_offset = 0x5'A0; // mRightArrow (GUIComponentAnimation*) + static constexpr std::size_t numbox_label_text_offset = 0x5'A8; // mTextBox (GUIComponentTextBox*, the label) + static constexpr std::size_t numbox_sizeof = 0x5'D0; + // Scalar deleting destructor slot in the GUIComponent vtable. Called with flags=0 it destructs + // and frees any owned sub-components without the final operator delete, so we then _aligned_free. + static constexpr std::size_t vtable_deleting_dtor_offset = 0x1'88; using ctor_fn = void* (*)(void* button, void* owner_screen); using push_back_fn = void (*)(void* vector, GUIComponent** value); @@ -123,6 +145,9 @@ namespace big::mod_settings using add_screen_fn = void (*)(void* screen_manager, void* screen, bool add_at_end, void* eastl_name); using show_text_fn = void (*)(void* text_box, const char* text); using get_lines_fn = void* (*)(void* text_box); + using numbox_factory_fn = void* (*)(const char* file, int line, const char* tag, void** screen); + using numbox_set_range_fn = void (*)(void* num_box, float min, float max); + using numbox_set_value_fn = void (*)(void* num_box, float value, bool notify); // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -149,6 +174,9 @@ namespace big::mod_settings static add_screen_fn g_add_screen = nullptr; static show_text_fn g_show_text = nullptr; static get_lines_fn g_get_lines = nullptr; + static numbox_factory_fn g_numbox_factory = nullptr; + static numbox_set_range_fn g_numbox_set_range = nullptr; + static numbox_set_value_fn g_numbox_set_value = nullptr; // sgg::KeyboardButtonId values used for edit confirm/cancel (validated in the PDB). static constexpr int key_escape = 0; @@ -167,6 +195,7 @@ namespace big::mod_settings static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column static constexpr float value_text_offset_x = 15.0f; // right-justify the value; right edge aligns with the toggle's + static constexpr float numbox_location_x = 1365.0f; // native OptionNumBox X (box + arrows clear the scrollbar) static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) static constexpr float row_base_y = 315.0f; // first row's Y (aligns with the tab column) static constexpr float row_pitch = 54.0f; // vertical distance between rows @@ -639,6 +668,90 @@ namespace big::mod_settings return row; } + // True for a finite whole number (used to pick integer vs float num-box display/stepping). + static bool is_whole(double v) + { + return std::isfinite(v) && v == std::floor(v); + } + + // Builds a native sgg::GUIComponentNumBox stepper row - identical to the game's own FPS-limit / + // graphics-quality options (boxed value flanked by Arrow_Left/Arrow_Right, left/right + arrow-click + // stepping, keyboard + controller). The game's factory allocates it, sets the correct vtable and + // builds all five sub-components (box graphic, label, value text, both arrows), which are also + // freed automatically when the row vectors are torn down - so no manual cleanup is needed. Value + // edits are persisted by the SetNumberValue hook (filtered to our rows). Returns the num-box + // component (not a GUIComponentButton, so it never routes through the OnClicked hook). + static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled) + { + if (!g_numbox_factory || !g_numbox_set_range || !g_numbox_set_value || !g_apply_data || !g_show_text) + { + return nullptr; + } + + void* scr = screen; + auto* nb = static_cast(g_numbox_factory("h2m", 0, "h2m::NumBox", &scr)); + if (!nb) + { + return nullptr; + } + char* nb_bytes = reinterpret_cast(nb); + + // Name the box and its sub-components so ApplyDataToComponent applies the matching sjson + // templates (its virtual ApplyDataToName routes each def by the sub-component's mName). + set_sso_string(nb_bytes + gui_component_name_offset, "OptionNumBox"); + if (void* value_tb = *reinterpret_cast(nb_bytes + numbox_value_text_offset)) + { + set_sso_string(static_cast(value_tb) + gui_component_name_offset, "OptionNumBoxValueText"); + } + if (void* left_arrow = *reinterpret_cast(nb_bytes + numbox_left_arrow_offset)) + { + set_sso_string(static_cast(left_arrow) + gui_component_name_offset, "OptionNumBoxLeftArrow"); + } + if (void* right_arrow = *reinterpret_cast(nb_bytes + numbox_right_arrow_offset)) + { + set_sso_string(static_cast(right_arrow) + gui_component_name_offset, "OptionNumBoxRightArrow"); + } + + // Integer box when the bounds and step are all whole (shows "3" not "3.0" and uses the discrete + // single-step path); otherwise a float box (decimals + analog repeat). Set the flag BEFORE + // SetRange, whose auto-step derives from it, then pin our own step. + const bool is_integer = is_whole(min_v) && is_whole(max_v) && is_whole(step_v); + *reinterpret_cast(nb_bytes + numbox_is_integer_offset) = is_integer; + + g_numbox_set_range(nb, static_cast(min_v), static_cast(max_v)); + *reinterpret_cast(nb_bytes + numbox_step_offset) = static_cast(step_v != 0.0 ? step_v : 1.0); + + g_apply_data(reinterpret_cast(screen), nb); + + // ApplyDataToComponent copies the OptionNumBox template's own row grid (Y=300, Spacing=45) + // into the component; override it to our grid so the box lines up with the other rows instead + // of drawing on the previous one. def_y/def_spacing alias the component's baseY(+0xC8) and + // pitch(+0x204) that UpdateScrollState reads (def sits at component+0xA8). + { + char* def = nb_bytes + component_def_offset; + *reinterpret_cast(def + def_y) = row_base_y; + *reinterpret_cast(def + def_spacing) = row_pitch; + } + + // The label lives in the num-box's own left text box (raw text, like our other rows). + if (void* label_tb = *reinterpret_cast(nb_bytes + numbox_label_text_offset)) + { + g_show_text(label_tb, label); + } + + // Paint the starting value; notify=false so the SetNumberValue hook does not persist it. + g_numbox_set_value(nb, static_cast(initial), false); + + if (disabled) + { + *reinterpret_cast(nb_bytes + numbox_disable_input_offset) = true; + } + + finalize_row(screen, nb); + nb->m_location_x = numbox_location_x; // override finalize_row's default so box + arrows clear the scrollbar + return nb; + } + // Removes the first pointer equal to `value` from an eastl vector by shifting the tail // down in place - the same unlink the engine's DoShowCategory performs. No-op if not // present; the backing storage is left owned by the vector. @@ -664,7 +777,7 @@ namespace big::mod_settings { auto* menu = reinterpret_cast(screen); - auto unlink_and_free = [&](GUIComponent* comp, bool in_options) + auto unlink_and_free = [&](GUIComponent* comp, bool in_options, bool is_numbox) { if (!comp) { @@ -697,7 +810,16 @@ namespace big::mod_settings vector_erase(screen->m_options, comp); } - if (g_button_dtor) + if (is_numbox) + { + // GUIComponentNumBox is not a GUIComponentButton; destruct it through its own vtable + // so its five sub-components (box/label/value/arrows) are freed too. flags=0 destructs + // without the final operator delete, so we still _aligned_free the block ourselves. + void** vtbl = *reinterpret_cast(comp); + auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset / sizeof(void*)]); + dtor(comp, 0); + } + else if (g_button_dtor) { g_button_dtor(comp); } @@ -706,8 +828,8 @@ namespace big::mod_settings for (const auto& row : g_rows) { - unlink_and_free(row.component, true); - unlink_and_free(row.value_component, false); + unlink_and_free(row.component, true, row.is_stepper); + unlink_and_free(row.value_component, false, false); } g_rows.clear(); @@ -1002,48 +1124,6 @@ namespace big::mod_settings return big::string::to_lower(key) == "enabled"; } - // Adjusts a bounded numeric stepper row by `direction` steps (+1 = increment, -1 = decrement). - // `wrap` cycles past a bound to the opposite one (used for a mouse click, so the value can be - // reached without arrow keys); otherwise the value is clamped to [min, max] (used for - // left/right, matching native number options). Writes the new value (which auto-saves and - // fires on_setting_changed), records any restart-required change, and refreshes the value - // label in place - no full rebuild, so rapid stepping stays smooth. - static void step_stepper_row(const PanelRow& row, int direction, bool wrap) - { - auto* entry = row.entry; - if (!entry || entry->type() != typeid(double) || row.disabled) - { - return; - } - - const double step = (row.stepper_step != 0.0) ? row.stepper_step : 1.0; - const double cur = entry->get_value_base(); - double next = cur + direction * step; - - if (next > row.stepper_max) - { - next = wrap ? row.stepper_min : row.stepper_max; - } - else if (next < row.stepper_min) - { - next = wrap ? row.stepper_max : row.stepper_min; - } - - if (next == cur) - { - return; // already at the clamped bound; nothing changed - } - - capture_restart_baseline(entry); - entry->set_value_base(next); // auto-saves via on_setting_changed - note_change_if_restart_required(entry, entry->get_serialized_value()); - - if (row.value_component && g_set_label) - { - g_set_label(row.value_component, entry->get_serialized_value().c_str()); - } - } - // Level 2: a Back row followed by one row per config entry belonging to `stem`. Boolean // entries render as native toggle rows; other types render as a left-aligned key with a // right-aligned, freetext-editable value (two components). A boolean "enabled" entry (if @@ -1102,12 +1182,23 @@ namespace big::mod_settings } const std::string label = (meta && !meta->name.empty()) ? meta->name : key_to_display(key); + // A numeric setting with author-declared min AND max renders as a native number box + // (boxed value + arrows, like the game's own FPS-limit option); otherwise numbers stay + // freetext-editable and the value shows as a plain right-column label. + const bool is_number = entry->type() == typeid(double); + const bool is_stepper = is_number && meta && meta->has_min && meta->has_max; + const double step = (meta && meta->has_step) ? meta->step : 1.0; + GUIComponent* row = nullptr; GUIComponent* value = nullptr; if (entry->type() == typeid(bool)) { row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), disabled); } + else if (is_stepper) + { + row = make_numbox_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), disabled); + } else { // Left-aligned key + right-aligned value (two components), like a keybind row. @@ -1128,14 +1219,12 @@ namespace big::mod_settings // Prefer the author's metadata description; fall back to the .cfg comment text. pr.description = (meta && !meta->description.empty()) ? meta->description : entry->m_description.m_description; - // A numeric setting with author-declared min AND max becomes a bounded stepper - // (left/right adjusts by step); otherwise numbers stay freetext-editable. - if (entry->type() == typeid(double) && meta && meta->has_min && meta->has_max) + if (is_stepper) { pr.is_stepper = true; pr.stepper_min = meta->min; pr.stepper_max = meta->max; - pr.stepper_step = meta->has_step ? meta->step : 1.0; + pr.stepper_step = step; } g_rows.push_back(pr); @@ -1164,6 +1253,32 @@ namespace big::mod_settings } } + // The component the user is currently on: the mouse-over one (mouse) takes priority, else the + // selected one (keyboard/controller). These are MenuScreen fields (flat struct view). + static GUIComponent* active_row_component(MiscSettingsScreen* screen) + { + auto* menu = reinterpret_cast(screen); + return menu->m_mouse_over_component ? menu->m_mouse_over_component : menu->m_selected_component; + } + + // Finds the PanelRow whose left-column component is `comp`, or nullptr. Valid until the next + // panel rebuild (deferred to Update), so callers within a single input/update pass may keep it. + static PanelRow* find_row(GUIComponent* comp) + { + if (!comp) + { + return nullptr; + } + for (auto& row : g_rows) + { + if (row.component == comp) + { + return &row; + } + } + return nullptr; + } + // The component whose description was last written to the description box, so the box is only // updated when the highlighted row changes (not every frame). Reset when the panel rebuilds. static GUIComponent* g_last_description_component = nullptr; @@ -1180,24 +1295,13 @@ namespace big::mod_settings } auto* box = screen->m_description_box; - GUIComponent* active = reinterpret_cast(screen)->m_mouse_over_component; - if (!active) - { - active = reinterpret_cast(screen)->m_selected_component; - } + GUIComponent* active = active_row_component(screen); // Resolve the highlighted row's description (cheap linear scan over the few visible rows). const std::string* description = nullptr; - if (active) + if (PanelRow* row = find_row(active)) { - for (const auto& row : g_rows) - { - if (row.component == active) - { - description = &row.description; - break; - } - } + description = &row->description; } const bool show = description && !description->empty(); @@ -1460,6 +1564,39 @@ namespace big::mod_settings return result; } + // Value-change hook for our native number-box rows. GUIComponentNumBox::SetNumberValue is called + // (with notify=true) on every user step - left/right, arrow click, keyboard or controller. We run + // the original first (it clamps to [min,max], refreshes the value text, updates arrow visibility), + // then, if `this` is one of our rows, persist the post-clamp value to the config entry and run the + // restart-required tracking. `notify` is false only for our own initial paint in make_numbox_row, + // so filtering on it keeps that from being recorded as a change. This fires for native settings + // num-boxes too, hence the `find_row` filter. + static void hook_GUIComponentNumBox_SetNumberValue(void* self, float value, bool notify) + { + big::g_hooking->get_original()(self, value, notify); + + if (!notify || !self) + { + return; + } + + PanelRow* row = find_row(reinterpret_cast(self)); + if (!row || !row->is_stepper || !row->entry) + { + return; + } + + const double new_value = static_cast(*reinterpret_cast(reinterpret_cast(self) + numbox_value_offset)); + if (row->entry->get_value_base() == new_value) + { + return; + } + + capture_restart_baseline(row->entry); + row->entry->set_value_base(new_value); // auto-saves via on_setting_changed + note_change_if_restart_required(row->entry, row->entry->get_serialized_value()); + } + // Button-click hook. GUIComponentButton overrides GUIComponent::OnClicked (vtable slot // +0x100, the engine's terminal-click), so this is where our button rows' clicks land. // For our rows the engine returns false (they have no bound activate function) but still @@ -1510,7 +1647,9 @@ namespace big::mod_settings case RowKind::setting: { auto* entry = matched_row.entry; - // Boolean settings toggle in place; bounded numbers step; other types edit. + // Boolean settings toggle in place; other types open a freetext editor. Number-box + // (stepper) rows are GUIComponentNumBox, not buttons, so their clicks never reach + // this hook - the num-box handles its own arrow clicks and left/right natively. if (entry && entry->type() == typeid(bool)) { // Capture the session baseline before the first write so a later revert to @@ -1534,12 +1673,6 @@ namespace big::mod_settings g_nav_pending = true; } } - else if (matched_row.is_stepper) - { - // A click on a bounded number steps it up, wrapping past the max back to - // the min so mouse users can reach every value without arrow keys. - step_stepper_row(matched_row, +1, true); - } else if (entry) { enter_edit_mode(matched_row.value_component, entry); @@ -1682,6 +1815,10 @@ namespace big::mod_settings // ShowText has a single overload, so it resolves by name. g_show_text = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::ShowText"].as_func(); g_get_lines = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GetLines"].as_func(); + // GUIComponentNumBox setters are single-overload named symbols; the factory is a template + // instantiation, so it is resolved by RVA off the anchor in the block below. + g_numbox_set_range = big::hades2_symbol_to_address["sgg::GUIComponentNumBox::SetRange"].as_func(); + g_numbox_set_value = big::hades2_symbol_to_address["sgg::GUIComponentNumBox::SetNumberValue"].as_func(); // MessageDialog::MessageDialog and ScreenManager::AddScreen are overloaded, so the PDB // symbol map cannot pick the wanted overload by name; resolve their DIA-validated RVAs @@ -1691,6 +1828,7 @@ namespace big::mod_settings const auto base = anchor.as() - anchor_rva; g_message_dialog_ctor = reinterpret_cast(base + message_dialog_ctor_rva); g_add_screen = reinterpret_cast(base + add_screen_rva); + g_numbox_factory = reinterpret_cast(base + numbox_factory_rva); } if (!g_push_back) @@ -1727,6 +1865,19 @@ namespace big::mod_settings << "[mod_settings] sgg::GUIComponentButton::OnClicked not found; mod rows will not be clickable"; } + const auto set_number_value = big::hades2_symbol_to_address["sgg::GUIComponentNumBox::SetNumberValue"]; + if (set_number_value) + { + static auto snv_hook = hooking::detour_hook_helper::add_queue( + "sgg::GUIComponentNumBox::SetNumberValue", + set_number_value); + } + else + { + LOG(WARNING) << "[mod_settings] sgg::GUIComponentNumBox::SetNumberValue not found; number-box edits will " + "not persist"; + } + const auto update = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::Update"]; if (update) { From cfa8c2cf24c3941bd9a207ab07ba7a320c510d3f Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:16:56 +0100 Subject: [PATCH 007/100] Add native enum cycler rows (num-box string mode) for the Mods tab --- src/hades2/mod_settings/mod_settings.cpp | 115 +++++++++++++++++++++-- 1 file changed, 106 insertions(+), 9 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 0bf106e..ce81c17 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -242,6 +242,14 @@ namespace big::mod_settings double stepper_min = 0.0; double stepper_max = 0.0; double stepper_step = 1.0; + + // Enum cycler (metadata has `values`). Rendered as a native number box over the index + // 0..labels-1 whose value text is overridden to the label (like the game's own enum + // options). `enum_values` are the serialized config values, `enum_labels` the parallel + // display strings; both indexed by the box's current integer value. + bool is_enum = false; + std::vector enum_values; + std::vector enum_labels; }; static std::vector g_rows; @@ -674,14 +682,30 @@ namespace big::mod_settings return std::isfinite(v) && v == std::floor(v); } + // Overrides a num-box's centered value text (its mValueTextBox) with raw text. Used for enum + // rows to show the option label instead of the raw index the box tracks internally. + static void set_numbox_value_text(GUIComponent* numbox, const char* text) + { + if (!g_show_text || !numbox) + { + return; + } + if (void* value_tb = *reinterpret_cast(reinterpret_cast(numbox) + numbox_value_text_offset)) + { + g_show_text(value_tb, text); + } + } + // Builds a native sgg::GUIComponentNumBox stepper row - identical to the game's own FPS-limit / // graphics-quality options (boxed value flanked by Arrow_Left/Arrow_Right, left/right + arrow-click // stepping, keyboard + controller). The game's factory allocates it, sets the correct vtable and // builds all five sub-components (box graphic, label, value text, both arrows), which are also // freed automatically when the row vectors are torn down - so no manual cleanup is needed. Value // edits are persisted by the SetNumberValue hook (filtered to our rows). Returns the num-box - // component (not a GUIComponentButton, so it never routes through the OnClicked hook). - static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled) + // component (not a GUIComponentButton, so it never routes through the OnClicked hook). When + // `value_labels` is non-null the box is an enum cycler: it steps the integer index and its value + // text is overridden to the matching label instead of the raw number. + static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled, const std::vector* value_labels = nullptr) { if (!g_numbox_factory || !g_numbox_set_range || !g_numbox_set_value || !g_apply_data || !g_show_text) { @@ -742,6 +766,21 @@ namespace big::mod_settings // Paint the starting value; notify=false so the SetNumberValue hook does not persist it. g_numbox_set_value(nb, static_cast(initial), false); + // Enum cycler: replace the raw index the box just painted with the option's label. + if (value_labels && !value_labels->empty()) + { + int idx = static_cast(initial); + if (idx < 0) + { + idx = 0; + } + else if (idx >= static_cast(value_labels->size())) + { + idx = static_cast(value_labels->size()) - 1; + } + set_numbox_value_text(nb, (*value_labels)[idx].c_str()); + } + if (disabled) { *reinterpret_cast(nb_bytes + numbox_disable_input_offset) = true; @@ -828,7 +867,7 @@ namespace big::mod_settings for (const auto& row : g_rows) { - unlink_and_free(row.component, true, row.is_stepper); + unlink_and_free(row.component, true, row.is_stepper || row.is_enum); unlink_and_free(row.value_component, false, false); } @@ -1182,19 +1221,45 @@ namespace big::mod_settings } const std::string label = (meta && !meta->name.empty()) ? meta->name : key_to_display(key); - // A numeric setting with author-declared min AND max renders as a native number box - // (boxed value + arrows, like the game's own FPS-limit option); otherwise numbers stay - // freetext-editable and the value shows as a plain right-column label. + // An enum (metadata `values`) renders as a native number box cycling its label list; a + // numeric setting with author-declared min AND max renders as a native number box over + // its range (like the FPS-limit option); other numbers stay freetext-editable with a + // plain right-column value label. const bool is_number = entry->type() == typeid(double); - const bool is_stepper = is_number && meta && meta->has_min && meta->has_max; + const bool is_enum = meta && !meta->values.empty(); + const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max; const double step = (meta && meta->has_step) ? meta->step : 1.0; + // Enum option lists (serialized values + parallel labels), resolved once so the widget and + // the PanelRow share them. The current value maps to its index, defaulting to 0. + std::vector enum_values; + std::vector enum_labels; + int enum_index = 0; + if (is_enum) + { + enum_values = meta->values; + enum_labels = (meta->labels.size() == enum_values.size()) ? meta->labels : enum_values; + const std::string cur = entry->get_serialized_value(); + for (int i = 0; i < static_cast(enum_values.size()); ++i) + { + if (enum_values[i] == cur) + { + enum_index = i; + break; + } + } + } + GUIComponent* row = nullptr; GUIComponent* value = nullptr; if (entry->type() == typeid(bool)) { row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), disabled); } + else if (is_enum) + { + row = make_numbox_row(screen, label.c_str(), 0.0, static_cast(enum_values.size() - 1), 1.0, static_cast(enum_index), disabled, &enum_labels); + } else if (is_stepper) { row = make_numbox_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), disabled); @@ -1219,7 +1284,13 @@ namespace big::mod_settings // Prefer the author's metadata description; fall back to the .cfg comment text. pr.description = (meta && !meta->description.empty()) ? meta->description : entry->m_description.m_description; - if (is_stepper) + if (is_enum) + { + pr.is_enum = true; + pr.enum_values = std::move(enum_values); + pr.enum_labels = std::move(enum_labels); + } + else if (is_stepper) { pr.is_stepper = true; pr.stepper_min = meta->min; @@ -1581,7 +1652,33 @@ namespace big::mod_settings } PanelRow* row = find_row(reinterpret_cast(self)); - if (!row || !row->is_stepper || !row->entry) + if (!row || !row->entry) + { + return; + } + + // Enum cycler: the box tracks the option index; persist the matching serialized value and + // replace the raw index the original just painted with the option's label. + if (row->is_enum) + { + int idx = static_cast(*reinterpret_cast(reinterpret_cast(self) + numbox_value_offset)); + if (idx < 0 || idx >= static_cast(row->enum_values.size())) + { + return; + } + set_numbox_value_text(reinterpret_cast(self), row->enum_labels[idx].c_str()); + + const std::string& serialized = row->enum_values[idx]; + if (row->entry->get_serialized_value() != serialized) + { + capture_restart_baseline(row->entry); + row->entry->set_serialized_value(serialized); // auto-saves via on_setting_changed + note_change_if_restart_required(row->entry, row->enum_labels[idx]); + } + return; + } + + if (!row->is_stepper) { return; } From fcc170176047db0e9e41096d50220b86471ff098 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:44:41 +0100 Subject: [PATCH 008/100] Escape markup, add display_name override, and truncate long value displays in the Mods tab --- src/hades2/mod_settings/config_api.cpp | 97 ++++++++++++++++- src/hades2/mod_settings/mod_settings.cpp | 132 ++++++++++++++++++++--- src/hades2/mod_settings/mod_settings.hpp | 5 + 3 files changed, 213 insertions(+), 21 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 7c1e4fb..592c586 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -1,11 +1,17 @@ #include "mod_settings.hpp" +#include +#include +#include #include +#include #include #include #include #include +#include #include +#include #include #include @@ -27,6 +33,12 @@ namespace big::mod_settings static std::mutex g_metadata_mutex; static std::map g_setting_metadata; + // Per-setting appearance order (rank of a key's definition in config.lua), populated for EVERY + // bound key (not just those with rich metadata). Keyed the same way as g_setting_metadata. The + // menu uses it to order rows that have no author-declared `order` in their config-file source + // order, because Lua pairs() and the alphabetical config map both lose the config.lua order. + static std::map g_appearance_order; + static std::string metadata_key(const std::string& guid, const std::string& section, const std::string& key) { std::string k; @@ -48,6 +60,10 @@ namespace big::mod_settings { it = (it->first.rfind(prefix, 0) == 0) ? g_setting_metadata.erase(it) : std::next(it); } + for (auto it = g_appearance_order.begin(); it != g_appearance_order.end();) + { + it = (it->first.rfind(prefix, 0) == 0) ? g_appearance_order.erase(it) : std::next(it); + } } bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key) @@ -68,6 +84,47 @@ namespace big::mod_settings return it->second; } + int get_setting_appearance_order(const std::string& guid, const std::string& section, const std::string& key) + { + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_appearance_order.find(metadata_key(guid, section, key)); + return it != g_appearance_order.end() ? it->second : INT_MAX; + } + + // Finds the byte offset of a key's definition (" =") in config.lua source, whole-word and + // not "==", or npos. The first match is the key's place in the returned `config` defaults table + // (defined before configDesc), which is the author's intended display order. Occurrences inside + // strings/prose don't match because they are not followed by a bare '='. + static std::size_t find_key_definition(const std::string& src, const std::string& key) + { + auto is_ident = [](char c) + { + return std::isalnum(static_cast(c)) != 0 || c == '_'; + }; + + for (std::size_t pos = src.find(key); pos != std::string::npos; pos = src.find(key, pos + 1)) + { + if (pos > 0 && is_ident(src[pos - 1])) + { + continue; // not a word boundary on the left (e.g. "my_key" when searching "key") + } + std::size_t after = pos + key.size(); + if (after < src.size() && is_ident(src[after])) + { + continue; // not a word boundary on the right + } + while (after < src.size() && (src[after] == ' ' || src[after] == '\t')) + { + ++after; + } + if (after < src.size() && src[after] == '=' && (after + 1 >= src.size() || src[after + 1] != '=')) + { + return pos; + } + } + return std::string::npos; + } + // Extracts a description string from a config.lua description value, which may be a plain string // or a table with a `description` field (or `[1]` shorthand). static std::string describe(const sol::object& desc) @@ -144,10 +201,11 @@ namespace big::mod_settings setting_metadata m; m.description = describe(desc); - sol::object name = desc["name"]; - if (name.get_type() == sol::type::string) + // Display-name override (`display_name`); empty -> the menu prettifies the key. + sol::object display_name = desc["display_name"]; + if (display_name.get_type() == sol::type::string) { - m.name = name.as(); + m.name = display_name.as(); } sol::object min_field = desc["min"]; @@ -411,7 +469,33 @@ namespace big::mod_settings } cf->save(); - // Register this mod's setting metadata (replacing any from a previous load of the same mod). + // Read config.lua source to recover the author's key order (Lua pairs() and the alphabetical + // config map both lose it), then rank every bound key by where it is defined. + std::string source_text; + { + std::ifstream file(config_lua_path, std::ios::binary); + if (file) + { + std::ostringstream ss; + ss << file.rdbuf(); + source_text = ss.str(); + } + } + std::vector> by_offset; // (offset, section, key) + for (const auto& [def, entry] : cf->m_entries) + { + const std::size_t off = source_text.empty() ? std::string::npos : find_key_definition(source_text, def.m_key); + by_offset.emplace_back(off, def.m_section, def.m_key); + } + std::stable_sort(by_offset.begin(), + by_offset.end(), + [](const auto& a, const auto& b) + { + return std::get<0>(a) < std::get<0>(b); + }); + + // Register this mod's setting metadata + appearance order (replacing any from a previous load + // of the same mod). { std::scoped_lock lock(g_metadata_mutex); clear_metadata_for(guid); @@ -419,6 +503,11 @@ namespace big::mod_settings { g_setting_metadata[metadata_key(guid, cm.section, cm.key)] = std::move(cm.meta); } + int rank = 0; + for (const auto& [off, section, key] : by_offset) + { + g_appearance_order[metadata_key(guid, section, key)] = rank++; + } } return sol::make_object(ts, mod_config_proxy{cf.get(), "config"}); diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index ce81c17..8014bd4 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -3,6 +3,7 @@ #include "sgg_gui.hpp" #include +#include #include #include #include @@ -201,6 +202,12 @@ namespace big::mod_settings static constexpr float row_pitch = 54.0f; // vertical distance between rows static constexpr std::uint32_t rows_per_page = 8; + // Max characters shown in the right-column value (freetext + its edit cursor). A long value is + // right-aligned and grows left toward the left-aligned key label; capping it keeps the two from + // overlapping. Truncation keeps the TAIL with a leading ellipsis (the informative end of a path, + // and where the edit cursor sits). Tunable; ~30 clears typical key labels at the value font. + static constexpr std::size_t value_display_max_chars = 30; + // Edit-cursor blink half-period (ms): the "|" shows for this long, then hides. static constexpr std::uint64_t edit_cursor_blink_ms = 500; @@ -311,6 +318,47 @@ namespace big::mod_settings return name; } + // Escapes the characters the game's text parser (GUIComponentTextBox::Parse) treats as markup, + // so arbitrary user text - config values (e.g. Windows paths with '\'), display names and + // descriptions - renders verbatim instead of being mangled. The parser reads '\' as an escape + // lead that consumes the following word ("D:\Program..." -> "D: ...") and '[' ']' as inline-tag + // delimiters whose contents are dropped ("[deprecated] x" -> " x"). A leading backslash makes + // each literal (\\ -> \, \[ -> [, \] -> ]); backslash MUST be escaped first. ('{' and '@' are + // also markup leads but have no literal escape in the parser, so are left as-is - they are rare + // in config text and, unlike '\'/'[', do not silently eat surrounding characters.) + static std::string escape_markup(const std::string& text) + { + std::string out; + out.reserve(text.size() + 8); + for (char c : text) + { + if (c == '\\' || c == '[' || c == ']') + { + out.push_back('\\'); + } + out.push_back(c); + } + return out; + } + + // Caps an over-long value string for the right-aligned value column so it does not run left into + // the option's key label. Keeps the TAIL with a leading ellipsis (most informative for a path, + // and where the append/backspace edit cursor sits). Operates on the logical (pre-escape) string; + // escape the result afterwards. The cut is nudged off any UTF-8 continuation byte. + static std::string truncate_value(const std::string& text) + { + if (text.size() <= value_display_max_chars) + { + return text; + } + std::size_t start = text.size() - (value_display_max_chars - 3); // room for the "..." prefix + while (start < text.size() && (static_cast(text[start]) & 0xC0) == 0x80) + { + ++start; // don't start mid-codepoint + } + return "..." + text.substr(start); + } + static GUIComponent* mods_category_button(MiscSettingsScreen* screen) { return reinterpret_cast(screen->m_editor_options_button); @@ -682,8 +730,8 @@ namespace big::mod_settings return std::isfinite(v) && v == std::floor(v); } - // Overrides a num-box's centered value text (its mValueTextBox) with raw text. Used for enum - // rows to show the option label instead of the raw index the box tracks internally. + // Overrides a num-box's centered value text (its mValueTextBox) with an enum option label. The + // label is escaped so paths/brackets in the option text render verbatim (see escape_markup). static void set_numbox_value_text(GUIComponent* numbox, const char* text) { if (!g_show_text || !numbox) @@ -692,7 +740,7 @@ namespace big::mod_settings } if (void* value_tb = *reinterpret_cast(reinterpret_cast(numbox) + numbox_value_text_offset)) { - g_show_text(value_tb, text); + g_show_text(value_tb, escape_markup(text).c_str()); } } @@ -905,7 +953,7 @@ namespace big::mod_settings for (const auto& [display, stem] : mods) { - if (auto* row = make_text_row(screen, display.c_str())) + if (auto* row = make_text_row(screen, escape_markup(display).c_str())) { g_rows.push_back({row, RowKind::mod_entry, stem, {}}); } @@ -1151,8 +1199,10 @@ namespace big::mod_settings { if (g_edit_component && g_set_label) { - const bool cursor_on = ((GetTickCount64() / edit_cursor_blink_ms) % 2) == 0; - const std::string label = g_edit_buffer + (cursor_on ? "|" : " "); + const bool cursor_on = ((GetTickCount64() / edit_cursor_blink_ms) % 2) == 0; + // Cap to the tail (where the cursor is), escape (a path may contain '\'), then append the + // raw blink cursor. + const std::string label = escape_markup(truncate_value(g_edit_buffer)) + (cursor_on ? "|" : " "); g_set_label(g_edit_component, label.c_str()); } } @@ -1175,8 +1225,18 @@ namespace big::mod_settings g_rows.push_back({row, RowKind::back, stem, {}}); } - // Gather this mod's entries, keeping map order, and locate the master "enabled" one. - std::vector> entries; // (key, entry) + // Gather this mod's entries. The config map is ordered alphabetically by (section, key), which + // is the current appearance order and the fallback for rows without an author-declared order. + struct panel_entry + { + std::string key; + toml_v2::config_file::config_entry_base* entry = nullptr; + bool has_order = false; + double order = 0.0; + int appearance = INT_MAX; // config.lua source rank (fallback order) + }; + + std::vector entries; toml_v2::config_file::config_entry_base* enabled_entry = nullptr; for (auto* cfg : toml_v2::config_file::g_config_files) { @@ -1190,7 +1250,16 @@ namespace big::mod_settings { continue; } - entries.emplace_back(key.m_key, entry.get()); + panel_entry pe; + pe.key = key.m_key; + pe.entry = entry.get(); + pe.appearance = get_setting_appearance_order(stem, key.m_section, key.m_key); + if (const auto meta = get_setting_metadata(stem, key.m_section, key.m_key); meta && meta->has_order) + { + pe.has_order = true; + pe.order = meta->order; + } + entries.push_back(std::move(pe)); if (!enabled_entry && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) { enabled_entry = entry.get(); @@ -1198,18 +1267,42 @@ namespace big::mod_settings } } - // Pin the enabled entry to the top; the rest keep their order. + // Row order: the master "enabled" toggle is always pinned to the top; then rows with an + // author-declared `order` (ascending); then rows with no `order`. Within each of those two + // groups, and to break equal `order` values, rows fall back to their config.lua source order + // (appearance rank). stable_sort keeps any remaining ties in the config-map order. std::stable_sort(entries.begin(), entries.end(), - [&](const auto& a, const auto& b) + [&](const panel_entry& a, const panel_entry& b) { - return (a.second == enabled_entry) && (b.second != enabled_entry); + const bool a_enabled = (a.entry == enabled_entry); + const bool b_enabled = (b.entry == enabled_entry); + if (a_enabled != b_enabled) + { + return a_enabled; // enabled toggle first + } + if (a_enabled) + { + return false; // only one enabled entry exists + } + if (a.has_order != b.has_order) + { + return a.has_order; // ordered rows before unordered ones + } + if (a.has_order && a.order != b.order) + { + return a.order < b.order; + } + return a.appearance < b.appearance; // equal/absent order -> config.lua source order }); const bool mod_enabled = !enabled_entry || enabled_entry->get_value_base(); - for (const auto& [key, entry] : entries) + for (const auto& row_src : entries) { + const std::string& key = row_src.key; + auto* entry = row_src.entry; + const bool is_enabled_row = (entry == enabled_entry); const bool disabled = !is_enabled_row && !mod_enabled; @@ -1219,7 +1312,7 @@ namespace big::mod_settings { continue; } - const std::string label = (meta && !meta->name.empty()) ? meta->name : key_to_display(key); + const std::string label = escape_markup((meta && !meta->name.empty()) ? meta->name : key_to_display(key)); // An enum (metadata `values`) renders as a native number box cycling its label list; a // numeric setting with author-declared min AND max renders as a native number box over @@ -1271,7 +1364,7 @@ namespace big::mod_settings if (row) { const std::string v = entry ? entry->get_serialized_value() : std::string{}; - value = make_value_display(screen, v.c_str(), disabled); + value = make_value_display(screen, escape_markup(truncate_value(v)).c_str(), disabled); } } @@ -1380,7 +1473,9 @@ namespace big::mod_settings if (active != g_last_description_component) { g_last_description_component = active; - g_show_text(box, show ? description->c_str() : ""); + // Escape markup so paths/brackets in the description render verbatim (see escape_markup). + const std::string shown = show ? escape_markup(*description) : std::string{}; + g_show_text(box, shown.c_str()); // ShowText only marks the lines dirty; the layout (and text height, which the box's // justification uses to place the text) is otherwise recomputed lazily at draw time, @@ -1562,7 +1657,10 @@ namespace big::mod_settings char* handle = reinterpret_cast(message_box) + textbox_font_handle_offset; *reinterpret_cast(handle + font_handle_size_ratio_offset) *= restart_message_font_scale; *reinterpret_cast(handle + font_handle_eng_size_ratio_offset) *= restart_message_font_scale; - g_show_text(message_box, message.c_str()); + // Escape markup so a path value (e.g. hadesGameFolder) with '\' or brackets in + // the changed-settings list renders verbatim (see escape_markup). + const std::string shown = escape_markup(message); + g_show_text(message_box, shown.c_str()); } } diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 5da08ee..d2761ad 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -47,4 +47,9 @@ namespace big::mod_settings // Returns the author-declared metadata for a setting, or std::nullopt when the setting has no // rich metadata table (in which case the menu renders it with type-based defaults). std::optional get_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); + + // Rank of a setting's definition in its config.lua source (0 = first). Used to order rows that + // have no author-declared `order` in config-file order. Returns INT_MAX for keys not bound via + // rom.mod_settings.load (e.g. Chalk-bound), so they fall back to the config map order. + int get_setting_appearance_order(const std::string& guid, const std::string& section, const std::string& key); } // namespace big::mod_settings From d0c6b942f6196a4dc4f2ecddae1c77776ac64f5f Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:49:18 +0100 Subject: [PATCH 009/100] Add freetext caret navigation and fit Mods-tab value displays by glyph width --- src/hades2/mod_settings/mod_settings.cpp | 326 ++++++++++++++++++++--- 1 file changed, 290 insertions(+), 36 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 8014bd4..3c9d60b 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -202,11 +202,12 @@ namespace big::mod_settings static constexpr float row_pitch = 54.0f; // vertical distance between rows static constexpr std::uint32_t rows_per_page = 8; - // Max characters shown in the right-column value (freetext + its edit cursor). A long value is - // right-aligned and grows left toward the left-aligned key label; capping it keeps the two from - // overlapping. Truncation keeps the TAIL with a leading ellipsis (the informative end of a path, - // and where the edit cursor sits). Tunable; ~30 clears typical key labels at the value font. - static constexpr std::size_t value_display_max_chars = 30; + // Approximate visual width budget for the right-column value (freetext + its edit caret), in + // "width units" where a typical medium glyph is 1.0. The menu font is variable-width, so a raw + // character count looks inconsistent (a run of 'W' is far wider than a run of 'i'); budgeting by + // summed glyph weight keeps the shown value a consistent WIDTH so it does not run left into the + // key label. ~30 units ~= 30 average glyphs, matching the previously tuned character cap. + static constexpr float value_display_max_width = 30.0f; // Edit-cursor blink half-period (ms): the "|" shows for this long, then hides. static constexpr std::uint64_t edit_cursor_blink_ms = 500; @@ -303,9 +304,10 @@ namespace big::mod_settings static toml_v2::config_file::config_entry_base* g_edit_entry = nullptr; static std::string g_edit_buffer; - static bool g_edit_numeric = false; // restrict input to a numeric literal - static bool g_edit_confirm = false; - static bool g_edit_cancel = false; + static std::size_t g_edit_cursor = 0; // caret position as a byte index into g_edit_buffer + static bool g_edit_numeric = false; // restrict input to a numeric literal + static bool g_edit_confirm = false; + static bool g_edit_cancel = false; // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the // author (up to the first '-') and shows the mod name with '_' replaced by spaces. @@ -341,20 +343,162 @@ namespace big::mod_settings return out; } - // Caps an over-long value string for the right-aligned value column so it does not run left into + // --- Text metrics + caret helpers (byte indices into a string; UTF-8 aware) --- + + // Approximate width of a single byte in the value font, in the same units as + // value_display_max_width (medium glyph = 1.0). The menu font (P22UndergroundSCMedium) is + // variable-width; these rough classes are enough to fit values by visual width instead of raw + // character count (exact pixel measurement is intentionally avoided - it would need the engine's + // SpriteFont globals). A UTF-8 lead byte counts once as a medium glyph; continuation bytes add 0. + static float glyph_weight(unsigned char c) + { + if (c >= 0xC0) + { + return 1.0f; // UTF-8 lead byte: count the codepoint once + } + if (c >= 0x80) + { + return 0.0f; // UTF-8 continuation byte + } + switch (c) + { + case ' ': + case '!': + case '\'': + case ',': + case '.': + case ':': + case ';': + case '|': + case '`': + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + case 'i': + case 'j': + case 'l': + case 'I': + case 'f': + case 't': + case 'r': return 0.5f; // narrow glyphs + case 'm': + case 'w': + case 'M': + case 'W': + case '@': + case '%': return 1.5f; // wide glyphs + default: return 1.0f; // medium (digits, most letters) + } + } + + // Summed approximate visual width of a string (see glyph_weight). + static float measure_width(const std::string& s) + { + float w = 0.0f; + for (char c : s) + { + w += glyph_weight(static_cast(c)); + } + return w; + } + + // True for a "word" byte: ASCII alphanumeric, underscore, or any UTF-8 byte (>=0x80, so non-ASCII + // letters count as word characters). Used for Ctrl+Left/Right word skip. + static bool is_word_byte(char c) + { + const unsigned char u = static_cast(c); + return (u >= '0' && u <= '9') || (u >= 'A' && u <= 'Z') || (u >= 'a' && u <= 'z') || u == '_' || u >= 0x80; + } + + // Caret one codepoint to the left (skips UTF-8 continuation bytes so a multibyte char moves as + // a unit). + static std::size_t caret_prev(const std::string& s, std::size_t pos) + { + if (pos == 0) + { + return 0; + } + --pos; + while (pos > 0 && (static_cast(s[pos]) & 0xC0) == 0x80) + { + --pos; + } + return pos; + } + + // Caret one codepoint to the right. + static std::size_t caret_next(const std::string& s, std::size_t pos) + { + if (pos >= s.size()) + { + return s.size(); + } + ++pos; + while (pos < s.size() && (static_cast(s[pos]) & 0xC0) == 0x80) + { + ++pos; + } + return pos; + } + + // Caret to the start of the current/previous word (Ctrl+Left): skip any non-word bytes to the + // left, then the run of word bytes. + static std::size_t caret_prev_word(const std::string& s, std::size_t pos) + { + while (pos > 0 && !is_word_byte(s[pos - 1])) + { + --pos; + } + while (pos > 0 && is_word_byte(s[pos - 1])) + { + --pos; + } + return pos; + } + + // Caret to the start of the next word (Ctrl+Right): skip the current run of word bytes, then the + // following non-word bytes. + static std::size_t caret_next_word(const std::string& s, std::size_t pos) + { + const std::size_t n = s.size(); + while (pos < n && is_word_byte(s[pos])) + { + ++pos; + } + while (pos < n && !is_word_byte(s[pos])) + { + ++pos; + } + return pos; + } + + // Caps an over-wide value string for the right-aligned value column so it does not run left into // the option's key label. Keeps the TAIL with a leading ellipsis (most informative for a path, - // and where the append/backspace edit cursor sits). Operates on the logical (pre-escape) string; - // escape the result afterwards. The cut is nudged off any UTF-8 continuation byte. + // and where the append/backspace edit caret sits). Fits by summed glyph WIDTH, not character + // count, so wide/narrow text shows a consistent visual width. Operates on the logical (pre-escape) + // string; escape the result afterwards. static std::string truncate_value(const std::string& text) { - if (text.size() <= value_display_max_chars) + if (measure_width(text) <= value_display_max_width) { return text; } - std::size_t start = text.size() - (value_display_max_chars - 3); // room for the "..." prefix - while (start < text.size() && (static_cast(text[start]) & 0xC0) == 0x80) + const float avail = value_display_max_width - measure_width("..."); // leave room for the prefix + std::size_t start = text.size(); + float used = 0.0f; + while (start > 0) { - ++start; // don't start mid-codepoint + const std::size_t prev = caret_prev(text, start); + const float w = measure_width(text.substr(prev, start - prev)); + if (used + w > avail) + { + break; + } + used += w; + start = prev; } return "..." + text.substr(start); } @@ -968,9 +1112,87 @@ namespace big::mod_settings return display; } + // Renders the edit buffer with a caret marker at `cursor`, windowed by visual WIDTH so the caret + // stays visible and the whole string fits the value column (value_display_max_width) without + // running into the key label. The window grows outward from the caret (both sides) filling the + // budget by summed glyph width, reserving space for the caret and for whichever ellipses are + // actually shown. The caret is a blinking "|"/" "; hidden text is marked with a leading/trailing + // ellipsis. Each shown buffer segment is markup-escaped (a path may contain '\'); the caret and + // ellipses are literal. + static std::string render_edit_display(const std::string& buf, std::size_t cursor, bool blink_on) + { + const char* caret = blink_on ? "|" : " "; + const std::size_t len = buf.size(); + if (cursor > len) + { + cursor = len; + } + + const float caret_w = 0.6f; // reserve a little for the caret glyph + const float ellipsis_w = measure_width("..."); + + // Whole buffer (plus caret) fits: no truncation. + if (measure_width(buf) + caret_w <= value_display_max_width) + { + return escape_markup(buf.substr(0, cursor)) + caret + escape_markup(buf.substr(cursor)); + } + + // Grow a window [start, end) outward from the caret, one codepoint at a time, alternating + // left then right, while it still fits the budget (accounting for the ellipses each side will + // need). Left grows first each round so a right-aligned field shows preceding context. + std::size_t start = cursor; + std::size_t end = cursor; + float used = caret_w; + for (bool grew = true; grew;) + { + grew = false; + + if (start > 0) + { + const std::size_t prev = caret_prev(buf, start); + const float add = measure_width(buf.substr(prev, start - prev)); + const float overhead = (prev > 0 ? ellipsis_w : 0.0f) + (end < len ? ellipsis_w : 0.0f); + if (used + add + overhead <= value_display_max_width) + { + used += add; + start = prev; + grew = true; + } + } + + if (end < len) + { + const std::size_t next = caret_next(buf, end); + const float add = measure_width(buf.substr(end, next - end)); + const float overhead = (start > 0 ? ellipsis_w : 0.0f) + (next < len ? ellipsis_w : 0.0f); + if (used + add + overhead <= value_display_max_width) + { + used += add; + end = next; + grew = true; + } + } + } + + std::string out; + if (start > 0) + { + out += "..."; + } + out += escape_markup(buf.substr(start, cursor - start)); + out += caret; + out += escape_markup(buf.substr(cursor, end - cursor)); + if (end < len) + { + out += "..."; + } + return out; + } + // Accepts a character into a numeric edit buffer only if the result stays a plausible - // numeric literal: an optional leading sign, digits, at most one decimal point. - static bool numeric_char_ok(const std::string& buffer, char c) + // numeric literal: an optional leading sign (only at the front), digits, at most one decimal + // point. `cursor` is where the character would be inserted. + static bool numeric_char_ok(const std::string& buffer, std::size_t cursor, char c) { if (c >= '0' && c <= '9') { @@ -978,7 +1200,8 @@ namespace big::mod_settings } if (c == '-' || c == '+') { - return buffer.empty(); // sign only as the first character + // A sign is valid only inserted at the very front, and only if no sign is there already. + return cursor == 0 && (buffer.empty() || (buffer.front() != '-' && buffer.front() != '+')); } if (c == '.') { @@ -987,11 +1210,12 @@ namespace big::mod_settings return false; } - // Window-procedure callback: while a freetext setting is being edited, capture typed - // characters into the edit buffer. Runs on the game's message-pump thread (same thread - // as Update). Printable characters arrive via WM_CHAR; Backspace via WM_KEYDOWN; a mouse - // click anywhere commits the edit (Enter/Escape are read from the game input in the - // HandleInput hook, which also blocks the menu from reacting). + // Window-procedure callback: while a freetext setting is being edited, capture typed characters + // and caret movement into the edit buffer. Runs on the game's message-pump thread (same thread + // as Update). Printable characters arrive via WM_CHAR (inserted at the caret); Backspace/Delete, + // arrow movement (with Ctrl for word skip), Home/End via WM_KEYDOWN; a mouse click anywhere + // commits the edit (Enter/Escape are read from the game input in the HandleInput hook, which + // also blocks the menu from reacting). static void on_wndproc(HWND, UINT msg, WPARAM wparam, LPARAM) { if (!g_editing) @@ -1006,12 +1230,40 @@ namespace big::mod_settings return; } + if (g_edit_cursor > g_edit_buffer.size()) + { + g_edit_cursor = g_edit_buffer.size(); + } + if (msg == WM_KEYDOWN) { - // Only editing keys (Backspace) are handled here; Enter/Escape come from HandleInput. - if (wparam == VK_BACK && !g_edit_buffer.empty()) + const bool ctrl = (GetKeyState(VK_CONTROL) & 0x80'00) != 0; + switch (wparam) { - g_edit_buffer.pop_back(); + case VK_BACK: + if (g_edit_cursor > 0) + { + const std::size_t prev = caret_prev(g_edit_buffer, g_edit_cursor); + g_edit_buffer.erase(prev, g_edit_cursor - prev); + g_edit_cursor = prev; + } + break; + case VK_DELETE: + if (g_edit_cursor < g_edit_buffer.size()) + { + const std::size_t next = caret_next(g_edit_buffer, g_edit_cursor); + g_edit_buffer.erase(g_edit_cursor, next - g_edit_cursor); + } + break; + case VK_LEFT: + g_edit_cursor = ctrl ? caret_prev_word(g_edit_buffer, g_edit_cursor) : caret_prev(g_edit_buffer, g_edit_cursor); + break; + case VK_RIGHT: + g_edit_cursor = ctrl ? caret_next_word(g_edit_buffer, g_edit_cursor) : caret_next(g_edit_buffer, g_edit_cursor); + break; + case VK_HOME: g_edit_cursor = 0; break; + case VK_END: g_edit_cursor = g_edit_buffer.size(); break; + default: break; } return; } @@ -1024,11 +1276,12 @@ namespace big::mod_settings return; } const char ch = static_cast(c); - if (g_edit_numeric && !numeric_char_ok(g_edit_buffer, ch)) + if (g_edit_numeric && !numeric_char_ok(g_edit_buffer, g_edit_cursor, ch)) { return; } - g_edit_buffer.push_back(ch); + g_edit_buffer.insert(g_edit_cursor, 1, ch); + ++g_edit_cursor; } } @@ -1056,6 +1309,7 @@ namespace big::mod_settings g_edit_component = value_component; g_edit_entry = entry; g_edit_buffer = entry ? entry->get_serialized_value() : std::string{}; + g_edit_cursor = g_edit_buffer.size(); // caret starts at the end g_edit_numeric = entry && entry->type() != typeid(std::string); g_edit_confirm = false; g_edit_cancel = false; @@ -1066,8 +1320,10 @@ namespace big::mod_settings g_editing = false; g_edit_component = nullptr; g_edit_entry = nullptr; - g_edit_confirm = false; - g_edit_cancel = false; + g_edit_buffer.clear(); + g_edit_cursor = 0; + g_edit_confirm = false; + g_edit_cancel = false; } // Requests an in-place rebuild of the current settings view (to reflect a committed or @@ -1193,16 +1449,14 @@ namespace big::mod_settings return false; } - // Live-updates the edited value display (right column) with a blinking cursor. Called from - // Update while editing is active; g_edit_component is the row's value component. + // Live-updates the edited value display (right column) with a movable, blinking caret. Called + // from Update while editing is active; g_edit_component is the row's value component. static void update_edit_label() { if (g_edit_component && g_set_label) { - const bool cursor_on = ((GetTickCount64() / edit_cursor_blink_ms) % 2) == 0; - // Cap to the tail (where the cursor is), escape (a path may contain '\'), then append the - // raw blink cursor. - const std::string label = escape_markup(truncate_value(g_edit_buffer)) + (cursor_on ? "|" : " "); + const bool cursor_on = ((GetTickCount64() / edit_cursor_blink_ms) % 2) == 0; + const std::string label = render_edit_display(g_edit_buffer, g_edit_cursor, cursor_on); g_set_label(g_edit_component, label.c_str()); } } From d131072309324699379b4626dd17d85092b89fb9 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:12:56 +0100 Subject: [PATCH 010/100] Split camelCase setting keys when prettifying Mods-tab labels --- src/hades2/mod_settings/mod_settings.cpp | 40 +++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 3c9d60b..f47a600 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1104,12 +1104,44 @@ namespace big::mod_settings } } - // Setting key as a display string (underscores become spaces). + // Setting key as a display string: underscores become spaces, and camelCase / PascalCase word + // boundaries are split ("z_ThisConfigKey" -> "z This Config Key"). An acronym run splits before + // its final capital when that capital starts a lowercase word ("HTTPServer" -> "HTTP Server"). + // Authors can override this entirely with `display_name`. static std::string key_to_display(const std::string& key) { - std::string display = key; - std::replace(display.begin(), display.end(), '_', ' '); - return display; + const auto is_upper = [](char c) + { + return c >= 'A' && c <= 'Z'; + }; + const auto is_lower = [](char c) + { + return c >= 'a' && c <= 'z'; + }; + + std::string out; + out.reserve(key.size() + 8); + for (std::size_t i = 0; i < key.size(); ++i) + { + const char c = key[i]; + if (c == '_') + { + out.push_back(' '); + continue; + } + if (!out.empty() && out.back() != ' ') + { + const char prev = key[i - 1]; + const bool lower_to_upper = is_lower(prev) && is_upper(c); + const bool acronym_boundary = is_upper(prev) && is_upper(c) && (i + 1 < key.size()) && is_lower(key[i + 1]); + if (lower_to_upper || acronym_boundary) + { + out.push_back(' '); + } + } + out.push_back(c); + } + return out; } // Renders the edit buffer with a caret marker at `cursor`, windowed by visual WIDTH so the caret From 615d20fa6693f6765741a5a02cd5c36b351f5e33 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:30:46 +0100 Subject: [PATCH 011/100] Add freetext override for bounded number settings with step snapping --- src/hades2/mod_settings/config_api.cpp | 6 ++++ src/hades2/mod_settings/mod_settings.cpp | 42 ++++++++++++++++++++++-- src/hades2/mod_settings/mod_settings.hpp | 1 + 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 592c586..b487ca1 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -253,6 +253,12 @@ namespace big::mod_settings m.hidden = hidden_field.as(); } + sol::object freetext_field = desc["freetext"]; + if (freetext_field.is()) + { + m.freetext = freetext_field.as(); + } + m.restart_required = description_requires_restart(desc); return m; diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index f47a600..f8f95e4 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1465,6 +1465,43 @@ namespace big::mod_settings // valid value, so bad input for a number simply keeps the old value. g_edit_entry->set_serialized_value(g_edit_buffer); + // Clamp/snap a bounded number typed via freetext to match what the stepper would + // produce: keep it within [min, max] and, if a step is declared, snap to the nearest + // grid point min + k*step. (The native stepper enforces both; freetext does it on + // commit.) set_serialized_value above already parsed/validated the number. + if (g_edit_entry->type() == typeid(double)) + { + const auto meta = get_setting_metadata(g_edit_entry->m_config_file->m_config_file_stem_as_str, + g_edit_entry->m_definition.m_section, + g_edit_entry->m_definition.m_key); + if (meta && (meta->has_min || meta->has_max || meta->has_step)) + { + double v = g_edit_entry->get_value_base(); + + const auto clamp_range = [&](double x) + { + if (meta->has_min && x < meta->min) + { + x = meta->min; + } + if (meta->has_max && x > meta->max) + { + x = meta->max; + } + return x; + }; + + v = clamp_range(v); + if (meta->has_step && meta->step > 0.0) + { + const double base = meta->has_min ? meta->min : 0.0; + v = base + std::round((v - base) / meta->step) * meta->step; + v = clamp_range(v); // snapping may overshoot a bound + } + g_edit_entry->set_value_base(v); + } + } + // If the author declared this setting restart-required, flag/clear the restart. note_change_if_restart_required(g_edit_entry, g_edit_entry->get_serialized_value()); } @@ -1602,11 +1639,12 @@ namespace big::mod_settings // An enum (metadata `values`) renders as a native number box cycling its label list; a // numeric setting with author-declared min AND max renders as a native number box over - // its range (like the FPS-limit option); other numbers stay freetext-editable with a + // its range (like the FPS-limit option) UNLESS the author set `freetext` (e.g. for a very + // large range better typed than stepped); other numbers stay freetext-editable with a // plain right-column value label. const bool is_number = entry->type() == typeid(double); const bool is_enum = meta && !meta->values.empty(); - const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max; + const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max && !meta->freetext; const double step = (meta && meta->has_step) ? meta->step : 1.0; // Enum option lists (serialized values + parallel labels), resolved once so the widget and diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index d2761ad..14409b7 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -37,6 +37,7 @@ namespace big::mod_settings bool hidden = false; // author asked to omit this row entirely bool restart_required = false; // change only takes effect after a game restart + bool freetext = false; // force a bounded number to freetext entry (not the stepper) }; // True if a mod author declared this setting as requiring a game restart to take effect From 6dbca70c09cfff8f722239c79cbd75885916bed0 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:02:39 +0100 Subject: [PATCH 012/100] Refresh freetext value in place on commit to preserve scroll position --- src/hades2/mod_settings/mod_settings.cpp | 36 ++++++++++++++++-------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index f8f95e4..832f9aa 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1358,15 +1358,6 @@ namespace big::mod_settings g_edit_cancel = false; } - // Requests an in-place rebuild of the current settings view (to reflect a committed or - // reverted edit) on the next Update. - static void request_settings_rebuild() - { - g_pending_view = g_view; - g_pending_stem = g_view_stem; - g_nav_pending = true; - } - // Replaces each ASCII space with a non-breaking space (U+00A0, UTF-8 0xC2 0xA0). The message // textbox auto-wraps at breakable spaces (computed at the template font size, before our font // scaling), which would split a single logical line; non-breaking spaces keep it on one line. @@ -1447,6 +1438,18 @@ namespace big::mod_settings g_restart_required = !g_restart_changes.empty(); } + // Refreshes a freetext row's right-column value display to show `serialized`, formatted exactly + // as build_mod_settings renders it (width-truncated with a leading ellipsis, then markup-escaped). + // Used to reflect a committed or cancelled edit in place, without a panel rebuild. + static void refresh_value_display(GUIComponent* value_component, const std::string& serialized) + { + if (value_component && g_set_label) + { + const std::string disp = escape_markup(truncate_value(serialized)); + g_set_label(value_component, disp.c_str()); + } + } + // Commits or cancels a pending edit. Called from the HandleInput hook so it runs on the // same frame the triggering key/click is swallowed (HandleInput returns true that // frame), which prevents a submitting mouse click from also activating the row it lands @@ -1504,15 +1507,26 @@ namespace big::mod_settings // If the author declared this setting restart-required, flag/clear the restart. note_change_if_restart_required(g_edit_entry, g_edit_entry->get_serialized_value()); + + // Reflect the committed value in the right-hand display in place. Do NOT rebuild the + // panel here: a rebuild frees and recreates every row, which snaps the visible page + // back to the top while the scrollbar keeps the scrolled position, so the rows and + // the scrollbar desync until the next manual scroll. Only this one value changed, so + // just update its label (the native number-box rows persist the same in-place way). + refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); } exit_edit_mode(); - request_settings_rebuild(); return true; } if (g_edit_cancel) { + // Restore the display to the unchanged value (the live caret label was transient); no + // rebuild, for the same scroll-preservation reason as the commit path above. + if (g_edit_entry) + { + refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); + } exit_edit_mode(); - request_settings_rebuild(); return true; } return false; From 870af80ef5d7e28f02639102d7d81bd28be0c76f Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:47:44 +0100 Subject: [PATCH 013/100] Version-guard the Mods options tab so it skips cleanly on symbol/offset mismatch --- src/hades2/mod_settings/mod_settings.cpp | 199 ++++++++++++----------- 1 file changed, 108 insertions(+), 91 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 832f9aa..d415352 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -113,6 +113,10 @@ namespace big::mod_settings // label, value text, left/right arrows). Template instantiation, so resolved by RVA off the anchor. static constexpr std::uintptr_t numbox_factory_rva = 0x17'A5'30; + // eastl::vector::push_back, used only as a fallback when the named PDB symbol is + // missing (it is sometimes emitted inline). Resolved off the same button-ctor anchor. + static constexpr std::uintptr_t push_back_rva = 0x14'1E'D0; + // sgg::GUIComponentNumBox field offsets (DIA-validated on the current Ship build). sizeof 0x5D0; // derives directly from GUIComponent (not GUIComponentButton). static constexpr std::size_t numbox_value_offset = 0x5'40; // mNumberValue (float) @@ -179,6 +183,11 @@ namespace big::mod_settings static numbox_set_range_fn g_numbox_set_range = nullptr; static numbox_set_value_fn g_numbox_set_value = nullptr; + // Set true by register_hooks only once every engine symbol, RVA and offset the Mods tab needs has + // resolved for the running game build. While false no hooks are installed and the tab is absent; + // it also gates process-global side effects (the wndproc callback) as a safety net. + static bool g_feature_enabled = false; + // sgg::KeyboardButtonId values used for edit confirm/cancel (validated in the PDB). static constexpr int key_escape = 0; static constexpr int key_kp_enter = 113; @@ -1321,7 +1330,7 @@ namespace big::mod_settings static void ensure_wndproc_registered() { static bool registered = false; - if (registered || !g_renderer) + if (registered || !g_renderer || !g_feature_enabled) { return; } @@ -2321,63 +2330,106 @@ namespace big::mod_settings void register_hooks() { - const auto ctor = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::MiscSettingsScreen"]; - const auto do_show_category = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::DoShowCategory"]; + // Resolve every engine symbol, RVA and offset the Mods tab depends on up front. The symbol map + // is built from the game's live PDB, so if the game updates and a required function moved or was + // renamed it resolves to null here; likewise the hardcoded RVAs and struct offsets this feature + // was reverse-engineered against only match one specific Ship build. If anything required is + // missing we log exactly what and install NO hooks, so the tab is cleanly skipped instead of + // crashing the game. The rom.mod_settings Lua config API is wired separately (bind_config_api) + // and keeps working regardless, so mods can still author and read their config. + std::vector missing; + const auto require = [&](const char* name) -> gmAddress + { + const auto addr = big::hades2_symbol_to_address[name]; + if (!addr) + { + missing.push_back(name); + } + return addr; + }; - if (!ctor || !do_show_category) - { - LOG(WARNING) << "[mod_settings] MiscSettingsScreen symbols not found; Mods options tab unavailable"; - return; - } + // Functions we hook (installed below, once everything checks out). + const auto ctor = require("sgg::MiscSettingsScreen::MiscSettingsScreen"); + const auto do_show_category = require("sgg::MiscSettingsScreen::DoShowCategory"); + const auto on_clicked = require("sgg::GUIComponentButton::OnClicked"); + const auto update = require("sgg::MiscSettingsScreen::Update"); + const auto handle_input = require("sgg::MiscSettingsScreen::HandleInput"); + const auto set_number_value = require("sgg::GUIComponentNumBox::SetNumberValue"); + + // Engine helpers called while building and editing rows. A null call here would crash, so every + // one is required. The button ctor doubles as the RVA anchor for the templated/overloaded + // helpers resolved further down. + const auto anchor = require("sgg::GUIComponentButton::GUIComponentButton"); + g_button_ctor = anchor.as_func(); + g_set_label = require("sgg::GUIComponentButton::SetDisplayName").as_func(); + g_apply_data = require("sgg::MenuScreen::ApplyDataToComponent").as_func(); + g_update_scroll = require("sgg::MiscSettingsScreen::UpdateScrollState").as_func(); + g_set_animation = require("sgg::GUIComponentButton::SetAnimation").as_func(); + g_hash_lookup = require("sgg::HashGuid::Lookup").as_func(); + g_setup_component = require("sgg::ComponentData::SetupComponent").as_func(); + g_set_normal_texture = require("sgg::GUIComponentButton::SetNormalTexture").as_func(); + g_was_key_pressed = require("sgg::InputHandler::WasKeyPressed").as_func(); + g_show_text = require("sgg::GUIComponentTextBox::ShowText").as_func(); + g_numbox_set_range = require("sgg::GUIComponentNumBox::SetRange").as_func(); + g_numbox_set_value = set_number_value.as_func(); - g_set_label = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetDisplayName"].as_func(); - g_button_ctor = big::hades2_symbol_to_address["sgg::GUIComponentButton::GUIComponentButton"].as_func(); - g_apply_data = big::hades2_symbol_to_address["sgg::MenuScreen::ApplyDataToComponent"].as_func(); - g_update_scroll = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::UpdateScrollState"].as_func(); - g_set_animation = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetAnimation"].as_func(); - g_hash_lookup = big::hades2_symbol_to_address["sgg::HashGuid::Lookup"].as_func(); - g_setup_component = big::hades2_symbol_to_address["sgg::ComponentData::SetupComponent"].as_func(); - g_set_normal_texture = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetNormalTexture"].as_func(); - g_set_selected_texture = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetSelectedTexture"].as_func(); - g_button_dtor = big::hades2_symbol_to_address["sgg::GUIComponentButton::~GUIComponentButton"].as_func(); - g_disable = big::hades2_symbol_to_address["sgg::GUIComponentButton::Disable"].as_func(); - g_was_key_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasKeyPressed"].as_func(); g_push_back = big::hades2_symbol_to_address["eastl::vector::push_back"].as_func(); - // ShowText has a single overload, so it resolves by name. - g_show_text = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::ShowText"].as_func(); + // Optional helpers: every call site is null-guarded, so their absence only degrades a visual or + // teardown detail (never crashes) and must not gate the feature. g_get_lines = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GetLines"].as_func(); - // GUIComponentNumBox setters are single-overload named symbols; the factory is a template - // instantiation, so it is resolved by RVA off the anchor in the block below. - g_numbox_set_range = big::hades2_symbol_to_address["sgg::GUIComponentNumBox::SetRange"].as_func(); - g_numbox_set_value = big::hades2_symbol_to_address["sgg::GUIComponentNumBox::SetNumberValue"].as_func(); + g_set_selected_texture = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetSelectedTexture"].as_func(); + g_button_dtor = big::hades2_symbol_to_address["sgg::GUIComponentButton::~GUIComponentButton"].as_func(); + g_disable = big::hades2_symbol_to_address["sgg::GUIComponentButton::Disable"].as_func(); + + // The num-box factory (a template instantiation) and the restart-dialog ctor / AddScreen + // overloads cannot be picked by name from the PDB, so they are addressed by hardcoded RVA off + // the button-ctor anchor. Those RVAs - and every struct offset this feature uses - are valid + // only for the Ship build they were captured from. Fingerprint that build by checking the + // anchor sits at its known module RVA (game base taken from the live process). A mismatch means + // the game changed and our RVAs/offsets can no longer be trusted, so disable the whole tab. + uintptr_t game_base = 0; + std::size_t game_size = 0; + ::module_info_helper::get_module_base_and_size(&game_base, &game_size, nullptr); + const bool build_matches = anchor && game_base && (anchor.as() - game_base == anchor_rva); - // MessageDialog::MessageDialog and ScreenManager::AddScreen are overloaded, so the PDB - // symbol map cannot pick the wanted overload by name; resolve their DIA-validated RVAs - // off the button-ctor anchor (same approach as the g_push_back fallback below). - if (const auto anchor = big::hades2_symbol_to_address["sgg::GUIComponentButton::GUIComponentButton"]) + // push_back is a named PDB symbol but is occasionally emitted inline; fall back to its RVA. + if (!g_push_back && build_matches) { - const auto base = anchor.as() - anchor_rva; - g_message_dialog_ctor = reinterpret_cast(base + message_dialog_ctor_rva); - g_add_screen = reinterpret_cast(base + add_screen_rva); - g_numbox_factory = reinterpret_cast(base + numbox_factory_rva); + g_push_back = reinterpret_cast(anchor.as() - anchor_rva + push_back_rva); } - if (!g_push_back) { - const auto anchor = big::hades2_symbol_to_address["sgg::GUIComponentButton::GUIComponentButton"]; - if (anchor) - { - g_push_back = reinterpret_cast(anchor.as() - 0x11'5c'70 + 0x14'1e'd0); - } + missing.push_back("eastl::vector::push_back"); } - if (!g_button_ctor || !g_push_back || !g_apply_data || !g_set_label || !g_setup_component) + if (!missing.empty() || !build_matches) { - LOG(WARNING) << "[mod_settings] engine row helpers unresolved (ctor=" << (g_button_ctor != nullptr) << " push_back=" << (g_push_back != nullptr) << " apply=" << (g_apply_data != nullptr) << " label=" << (g_set_label != nullptr) << " setup=" << (g_setup_component != nullptr) << ")"; + std::string detail; + for (const auto* name : missing) + { + detail += "\n - missing symbol: "; + detail += name; + } + if (!build_matches) + { + detail += "\n - build fingerprint mismatch (button ctor not at the expected RVA; game updated?)"; + } + LOG(WARNING) << "[mod_settings] Mods options tab disabled for this game build; the in-game mod-settings " + "editor is skipped. The rom.mod_settings Lua config API is unaffected." + << detail; + return; } + // Build verified and every required symbol resolved: derive the RVA-relative helpers and hook. + const auto anchor_base = anchor.as() - anchor_rva; + g_message_dialog_ctor = reinterpret_cast(anchor_base + message_dialog_ctor_rva); + g_add_screen = reinterpret_cast(anchor_base + add_screen_rva); + g_numbox_factory = reinterpret_cast(anchor_base + numbox_factory_rva); + + g_feature_enabled = true; + static auto ctor_hook = hooking::detour_hook_helper::add_queue( "sgg::MiscSettingsScreen::MiscSettingsScreen", ctor); @@ -2385,55 +2437,20 @@ namespace big::mod_settings "sgg::MiscSettingsScreen::DoShowCategory", do_show_category); - const auto on_clicked = big::hades2_symbol_to_address["sgg::GUIComponentButton::OnClicked"]; - if (on_clicked) - { - static auto onclick_hook = hooking::detour_hook_helper::add_queue( - "sgg::GUIComponentButton::OnClicked", - on_clicked); - } - else - { - LOG(WARNING) - << "[mod_settings] sgg::GUIComponentButton::OnClicked not found; mod rows will not be clickable"; - } - - const auto set_number_value = big::hades2_symbol_to_address["sgg::GUIComponentNumBox::SetNumberValue"]; - if (set_number_value) - { - static auto snv_hook = hooking::detour_hook_helper::add_queue( - "sgg::GUIComponentNumBox::SetNumberValue", - set_number_value); - } - else - { - LOG(WARNING) << "[mod_settings] sgg::GUIComponentNumBox::SetNumberValue not found; number-box edits will " - "not persist"; - } - - const auto update = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::Update"]; - if (update) - { - static auto update_hook = hooking::detour_hook_helper::add_queue( - "sgg::MiscSettingsScreen::Update", - update); - } - else - { - LOG(WARNING) - << "[mod_settings] sgg::MiscSettingsScreen::Update not found; mod-row navigation is unavailable"; - } - - const auto handle_input = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::HandleInput"]; - if (handle_input) - { - static auto handle_input_hook = hooking::detour_hook_helper::add_queue("sgg::MiscSettingsScreen::HandleInput", handle_input); - } - else - { - LOG(WARNING) << "[mod_settings] sgg::MiscSettingsScreen::HandleInput not found; freetext editing may not " - "block menu nav"; - } + // All required by the checks above, so install unconditionally. OnClicked and SetNumberValue are + // global (they fire for every button / num-box in the game); their callbacks filter to our rows + // via find_row, so installing them is a no-op for the rest of the game's UI. + static auto onclick_hook = hooking::detour_hook_helper::add_queue( + "sgg::GUIComponentButton::OnClicked", + on_clicked); + static auto snv_hook = hooking::detour_hook_helper::add_queue( + "sgg::GUIComponentNumBox::SetNumberValue", + set_number_value); + static auto update_hook = + hooking::detour_hook_helper::add_queue("sgg::MiscSettingsScreen::Update", update); + static auto handle_input_hook = hooking::detour_hook_helper::add_queue( + "sgg::MiscSettingsScreen::HandleInput", + handle_input); // Every close path (Escape key, controller B, clicking the on-screen Exit button) funnels // through ExitScreen, so this is where the restart-required prompt is triggered. From 1d42f0138ceb4b9d511e23ec2f8990612213c3cd Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:36:59 +0100 Subject: [PATCH 014/100] Add per-mod Reset, context prompts, and vanilla row spacing to the Mods options tab --- src/hades2/mod_settings/config_api.cpp | 58 +++++- src/hades2/mod_settings/mod_settings.cpp | 230 ++++++++++++++++++++++- src/hades2/mod_settings/mod_settings.hpp | 5 + src/hades2/mod_settings/sgg_gui.hpp | 15 +- 4 files changed, 293 insertions(+), 15 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index b487ca1..696bbaf 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -1,6 +1,7 @@ #include "mod_settings.hpp" #include +#include #include #include #include @@ -9,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +41,11 @@ namespace big::mod_settings // order, because Lua pairs() and the alphabetical config map both lose the config.lua order. static std::map g_appearance_order; + // Serialized config.lua default for every bound key (whether or not it has a rich metadata + // table), captured at load. The settings menu's Reset action restores a setting to this value. + // Keyed the same way as g_setting_metadata (guid + '\0' + section + '\0' + key). + static std::map g_setting_default; + static std::string metadata_key(const std::string& guid, const std::string& section, const std::string& key) { std::string k; @@ -64,6 +71,10 @@ namespace big::mod_settings { it = (it->first.rfind(prefix, 0) == 0) ? g_appearance_order.erase(it) : std::next(it); } + for (auto it = g_setting_default.begin(); it != g_setting_default.end();) + { + it = (it->first.rfind(prefix, 0) == 0) ? g_setting_default.erase(it) : std::next(it); + } } bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key) @@ -91,6 +102,17 @@ namespace big::mod_settings return it != g_appearance_order.end() ? it->second : INT_MAX; } + std::optional get_setting_default(const std::string& guid, const std::string& section, const std::string& key) + { + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_setting_default.find(metadata_key(guid, section, key)); + if (it == g_setting_default.end()) + { + return std::nullopt; + } + return it->second; + } + // Finds the byte offset of a key's definition (" =") in config.lua source, whole-word and // not "==", or npos. The first match is the key's place in the returned `config` defaults table // (defined before configDesc), which is the author's intended display order. Occurrences inside @@ -366,7 +388,7 @@ namespace big::mod_settings // description is a rich table has its metadata extracted into `meta_out` (keyed by section+key). // config_file::bind adopts a value already saved in the .cfg, preserving user edits, and binds // under section "config" so the .cfg stays byte-compatible with what SGG_Modding-Chalk wrote. - static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector& meta_out) + static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector& meta_out, std::vector>& defaults_out) { sol::table desc_tbl; const bool has_desc = desc_obj.is(); @@ -390,15 +412,32 @@ namespace big::mod_settings } const sol::type vt = value_obj.get_type(); + std::optional default_any; switch (vt) { case sol::type::table: - bind_defaults(cf, value_obj.as(), desc, section + "." + key, meta_out); + bind_defaults(cf, value_obj.as(), desc, section + "." + key, meta_out, defaults_out); + break; + case sol::type::boolean: + cf->bind(section, key, value_obj.as(), describe(desc)); + default_any = std::any(value_obj.as()); + break; + case sol::type::number: + cf->bind(section, key, value_obj.as(), describe(desc)); + default_any = std::any(value_obj.as()); break; - case sol::type::boolean: cf->bind(section, key, value_obj.as(), describe(desc)); break; - case sol::type::number: cf->bind(section, key, value_obj.as(), describe(desc)); break; - case sol::type::string: cf->bind(section, key, value_obj.as(), describe(desc)); break; - default: continue; + case sol::type::string: + cf->bind(section, key, value_obj.as(), describe(desc)); + default_any = std::any(value_obj.as()); + break; + default: continue; + } + + // Capture the config.lua default, serialized exactly as the entry serializes its own + // value, so the menu's Reset can round-trip it back through set_serialized_value. + if (default_any) + { + defaults_out.emplace_back(section, key, toml_v2::toml_type_converter::convert_to_string(*default_any)); } // Only a rich description table carries metadata; a nested value is a sub-section (its @@ -469,9 +508,10 @@ namespace big::mod_settings // Bind the defaults into the config_file (section root "config", matching Chalk) and collect // each rich setting's metadata, then persist the file. std::vector collected; + std::vector> collected_defaults; // (section, key, serialized) if (defaults.is()) { - bind_defaults(cf.get(), defaults.as(), descriptions, "config", collected); + bind_defaults(cf.get(), defaults.as(), descriptions, "config", collected, collected_defaults); } cf->save(); @@ -509,6 +549,10 @@ namespace big::mod_settings { g_setting_metadata[metadata_key(guid, cm.section, cm.key)] = std::move(cm.meta); } + for (auto& [section, key, serialized] : collected_defaults) + { + g_setting_default[metadata_key(guid, section, key)] = std::move(serialized); + } int rank = 0; for (const auto& [off, section, key] : by_offset) { diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index d415352..b091bcb 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -207,9 +207,9 @@ namespace big::mod_settings static constexpr float value_text_offset_x = 15.0f; // right-justify the value; right edge aligns with the toggle's static constexpr float numbox_location_x = 1365.0f; // native OptionNumBox X (box + arrows clear the scrollbar) static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) - static constexpr float row_base_y = 315.0f; // first row's Y (aligns with the tab column) - static constexpr float row_pitch = 54.0f; // vertical distance between rows - static constexpr std::uint32_t rows_per_page = 8; + static constexpr float row_base_y = 300.0f; // first row's Y - matches the vanilla option templates + static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) + static constexpr std::uint32_t rows_per_page = 10; // vanilla ItemsPerPage = 10 // Approximate visual width budget for the right-column value (freetext + its edit caret), in // "width units" where a typical medium glyph is 1.0. The menu font is variable-width, so a raw @@ -305,6 +305,7 @@ namespace big::mod_settings static bool g_nav_pending = false; static View g_pending_view = View::mod_list; static std::string g_pending_stem; + static bool g_nav_reset_to_top = false; // Reset action: force a top (non-instant) rebuild next apply_nav // Freetext edit state (number/string settings). A click enters edit mode; typed input // is captured in the window procedure and applied on the game thread in the Update hook. @@ -1840,6 +1841,114 @@ namespace big::mod_settings box->m_fade_target = show ? 1.0f : 0.0f; } + // Last label we wrote to each bottom-prompt button, so SetDisplayName is only called when the + // label actually changes (avoids re-laying out the text every frame). Cleared when we leave the + // Mods tab so the native labels take back over and re-entering re-applies ours. + static std::string g_prompt_confirm_label; + static std::string g_prompt_cancel_label; + + // Sets a bottom-prompt button's label (GUIComponentButton::SetDisplayName) only when it changes + // from what we last set. The key glyph is driven by the button's bound control, not the label, so + // it stays correct (Enter for Confirm, Esc for Cancel) regardless of the text. + static void set_prompt_label(GUIComponent* button, std::string& cache, const char* text) + { + if (!button || !g_set_label || cache == text) + { + return; + } + cache.assign(text); + g_set_label(button, text); + } + + // Retunes the options screen's bottom button prompts for the Mods tab per context, and hides the + // native Reset prompt where it must not apply. Called every frame from the Update hook (after the + // original, which sets the native prompts on focus/hover/category events). Off the Mods tab it + // only clears our caches and leaves the native prompts untouched. + static void sync_prompts(MiscSettingsScreen* screen, bool on_mods_tab) + { + if (!on_mods_tab) + { + g_prompt_confirm_label.clear(); + g_prompt_cancel_label.clear(); + return; + } + + auto* menu = reinterpret_cast(screen); + + // The native prompt strings embed a glyph token that the text box expands to the device- + // appropriate key icon: "{CN}" = the Cancel control (Esc / B), "{SL}" = the Select/Confirm + // control (Enter / A). We prepend the same token to our custom labels so the icon is kept + // (a raw string with no token renders text only). Labels are upper-case to match the game. + // Cancel (Esc): "CANCEL" while editing a field; "BACK" inside a mod's settings (Esc returns to + // the mod list, see the ExitScreen hook); "EXIT" at the mod list (closes the options screen). + const char* cancel = g_editing ? "{CN} CANCEL" : (g_view == View::mod_settings ? "{CN} BACK" : "{CN} EXIT"); + set_prompt_label(menu->m_cancel_button, g_prompt_cancel_label, cancel); + + // Confirm (Enter): "SUBMIT" while editing; otherwise a verb matching the highlighted row. + std::string confirm; + if (g_editing) + { + confirm = "{SL} SUBMIT"; + } + else if (PanelRow* row = find_row(active_row_component(screen))) + { + switch (row->kind) + { + case RowKind::mod_entry: confirm = "{SL} SELECT"; break; + case RowKind::back: confirm = "{SL} SELECT"; break; + case RowKind::setting: + if (row->entry && row->entry->type() == typeid(bool)) + { + confirm = "{SL} TOGGLE"; + } + else if (row->is_enum) + { + confirm = "{SL} SET"; + } + else if (row->is_stepper) + { + confirm = "{SL} SELECT"; + } + else + { + confirm = "{SL} EDIT"; // freetext value + } + break; + case RowKind::action: confirm = "{SL} SELECT"; break; + } + } + + // Drive the Confirm prompt's visibility ourselves. Native only fades it in (OnOptionMouseOver) + // for its OWN option rows, which never fires for our custom rows, so it would otherwise stay + // invisible until first forced (e.g. by editing a field). Show it with its glyph whenever we + // have a hint, hide it when we don't. mFadeOpacity is the field the draw gate reads; native + // Update rewrites mHidden each frame, so both are set here (this runs after the original Update). + if (menu->m_confirm_button) + { + if (confirm.empty()) + { + menu->m_confirm_button->m_fade_opacity = 0.0f; + menu->m_confirm_button->m_fade_target = 0.0f; + } + else + { + set_prompt_label(menu->m_confirm_button, g_prompt_confirm_label, confirm.c_str()); + menu->m_confirm_button->m_hidden = false; + menu->m_confirm_button->m_fade_opacity = 1.0f; + menu->m_confirm_button->m_fade_target = 1.0f; + } + } + + // Reset prompt: shown only inside a single mod's settings (resets that mod) and not while + // editing. It is hidden in the mod list/overview so users cannot reset every mod's config by + // accident (the RestoreDefaults hook also swallows the shortcut there). + if (screen->m_defaults_button) + { + const bool show_reset = (g_view == View::mod_settings) && !g_editing; + screen->m_defaults_button->m_hidden = !show_reset; + } + } + static void build_panel(MiscSettingsScreen* screen, bool instant = false) { // A rebuild frees and recreates the row components, so the cached highlighted-row pointer @@ -1916,13 +2025,71 @@ namespace big::mod_settings // keeps the fade-in. static void apply_nav(MiscSettingsScreen* screen) { - const bool instant = (g_pending_view == g_view) && (g_pending_stem == g_view_stem); + // A Reset forces a top (non-instant) rebuild even though the view is unchanged, so the + // restored rows and the scrollbar stay in sync - an in-place rebuild that preserves a + // scrolled position would leave the stale page-1 rows visible (see the scroll-model notes). + const bool instant = !g_nav_reset_to_top && (g_pending_view == g_view) && (g_pending_stem == g_view_stem); + g_nav_reset_to_top = false; g_view = g_pending_view; g_view_stem = g_pending_stem; build_panel(screen, instant); } + // Restores the current mod's config entries (g_view_stem) to their config.lua defaults (see + // get_setting_default), saving each change and flagging any restart-required ones. Only ever + // resets the one mod whose settings are open - never every mod - so it is called only from the + // mod-settings view. Only settings bound via rom.mod_settings.load carry a captured default; + // anything else (raw rom.config or big::config) is left untouched. Returns true if any value + // actually changed. + static bool reset_settings_to_defaults() + { + bool any_changed = false; + for (auto* cfg : toml_v2::config_file::g_config_files) + { + if (!cfg || cfg->m_config_file_stem_as_str.empty() || cfg->m_config_file_stem_as_str != g_view_stem) + { + continue; + } + const std::string& guid = cfg->m_config_file_stem_as_str; + for (auto& [def, entry] : cfg->m_entries) + { + if (!entry) + { + continue; + } + auto* e = entry.get(); + + const auto def_val = get_setting_default(guid, def.m_section, def.m_key); + if (!def_val || e->get_serialized_value() == *def_val) + { + continue; + } + capture_restart_baseline(e); + e->set_serialized_value(*def_val); // auto-saves + fires on_setting_changed + note_change_if_restart_required(e, e->get_serialized_value()); + any_changed = true; + } + } + return any_changed; + } + + // Handles a Reset activation on the Mods tab: restores the in-scope settings to their config.lua + // defaults, then (in a mod's settings view, where the changed values are on screen) queues a top + // rebuild so the widgets show the restored values. Safe to call from input/click context because + // the rebuild is deferred to the Update hook. + static void perform_reset() + { + const bool changed = reset_settings_to_defaults(); + if (changed && g_view == View::mod_settings) + { + g_pending_view = g_view; + g_pending_stem = g_view_stem; + g_nav_pending = true; + g_nav_reset_to_top = true; + } + } + // Builds the restart-popup body text from the changes collected this session. Blank lines are a // single non-breaking space (U+00A0): ShowText trims ASCII-whitespace-only lines (so "\n\n" and // "\n \n" collapse) but keeps an nbsp line. A sacrificial trailing nbsp line is appended because @@ -2036,12 +2203,15 @@ namespace big::mod_settings g_view = View::mod_list; g_view_stem.clear(); g_nav_pending = false; + g_nav_reset_to_top = false; g_restart_required = false; g_restart_prompt_shown = false; g_restart_confirm_button = nullptr; g_restart_changes.clear(); g_restart_baselines.clear(); g_last_description_component = nullptr; + g_prompt_confirm_label.clear(); + g_prompt_cancel_label.clear(); exit_edit_mode(); // The engine constructor returns `this`; forward it unchanged. @@ -2274,6 +2444,10 @@ namespace big::mod_settings sync_description_box(screen); } + // Retune the bottom prompt buttons per context (off the Mods tab this only clears our + // caches and leaves the native prompts alone). + sync_prompts(screen, on_mods_tab); + return result; } @@ -2315,6 +2489,19 @@ namespace big::mod_settings // forced. If the native dialog cannot be shown, the MessageBox fallback closes the game anyway. static void hook_MiscSettingsScreen_ExitScreen(void* self) { + // Inside a mod's settings, Esc / controller B / the on-screen Back button navigates back to + // the mod list instead of closing the whole options screen (mirrors the native category + // drill-down, where the same button reads "Back"). Only the mod-list view actually closes. + auto* screen = static_cast(self); + const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); + if (on_mods_tab && g_view == View::mod_settings) + { + g_pending_view = View::mod_list; + g_pending_stem.clear(); + g_nav_pending = true; + return; // veto the close; apply_nav swaps back to the mod list next Update + } + if (g_restart_required && !g_restart_prompt_shown) { g_restart_prompt_shown = true; @@ -2328,6 +2515,28 @@ namespace big::mod_settings big::g_hooking->get_original()(self); } + // Reset choke-point: sgg::MiscSettingsScreen::RestoreDefaults (virtual slot 21) is the single + // handler for both the [I]/MenuInfo control and a mouse click on the on-screen Reset button. On + // our Mods tab the native reset is a no-op (our rows' mDataValue is not a ConfigOptionsField key). + // Inside a single mod's settings we run our own reset of that mod's config and still call the + // original for the native confirm animation + sound and glyph refresh (on our tab it touches no + // real game settings). In the mod list/overview we swallow it entirely: Reset is intentionally + // unavailable there (its prompt is hidden too) so users can't reset every mod's config by mistake. + static void hook_MiscSettingsScreen_RestoreDefaults(void* self) + { + auto* screen = static_cast(self); + if (screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button)) + { + if (g_view != View::mod_settings) + { + return; // reset removed in the mod list; do nothing (and do not play the native reset) + } + perform_reset(); + } + + big::g_hooking->get_original()(self); + } + void register_hooks() { // Resolve every engine symbol, RVA and offset the Mods tab depends on up front. The symbol map @@ -2464,5 +2673,18 @@ namespace big::mod_settings LOG(WARNING) << "[mod_settings] sgg::MiscSettingsScreen::ExitScreen not found; the restart-required prompt " "will not appear"; } + + // Optional: the on-screen "Reset" button ([I]/MenuInfo control or mouse) funnels through + // RestoreDefaults. Without it the Mods tab still works; Reset just won't restore mod defaults. + const auto restore_defaults = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::RestoreDefaults"]; + if (restore_defaults) + { + static auto restore_defaults_hook = hooking::detour_hook_helper::add_queue("sgg::MiscSettingsScreen::RestoreDefaults", restore_defaults); + } + else + { + LOG(WARNING) << "[mod_settings] sgg::MiscSettingsScreen::RestoreDefaults not found; the Reset button will " + "not reset mod settings"; + } } } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 14409b7..6fd8fd5 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -53,4 +53,9 @@ namespace big::mod_settings // have no author-declared `order` in config-file order. Returns INT_MAX for keys not bound via // rom.mod_settings.load (e.g. Chalk-bound), so they fall back to the config map order. int get_setting_appearance_order(const std::string& guid, const std::string& section, const std::string& key); + + // Returns the config.lua default (serialized like the config entry's value) for a setting bound + // via rom.mod_settings.load, or std::nullopt for keys with no captured default. Used by the + // settings menu's Reset action to restore a setting to what config.lua declared. + std::optional get_setting_default(const std::string& guid, const std::string& section, const std::string& key); } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp index 0615568..0f8abd7 100644 --- a/src/hades2/mod_settings/sgg_gui.hpp +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -99,13 +99,17 @@ namespace big::mod_settings::sgg char m_pad_mo[0x68]; GUIComponent* m_mouse_over_component; // +0xC0 eastl_vector m_components; // +0xC8 - char m_pad_sel[0xD0]; - GUIComponent* m_selected_component; // +0x1B0 + char m_pad_prompts[0xC0]; // 0xE0 .. 0x1A0 + GUIComponent* m_confirm_button; // +0x1A0 (bottom "Confirm/Select/Toggle" prompt) + GUIComponent* m_cancel_button; // +0x1A8 (bottom "Exit/Back" prompt) + GUIComponent* m_selected_component; // +0x1B0 }; static_assert(offsetof(MenuScreen, m_anchor) == 0x50); static_assert(offsetof(MenuScreen, m_mouse_over_component) == 0xC0); static_assert(offsetof(MenuScreen, m_components) == 0xC8); + static_assert(offsetof(MenuScreen, m_confirm_button) == 0x1'A0); + static_assert(offsetof(MenuScreen, m_cancel_button) == 0x1'A8); static_assert(offsetof(MenuScreen, m_selected_component) == 0x1'B0); // sgg::MiscSettingsScreen, the native tabbed options screen. The category buttons are @@ -130,8 +134,10 @@ namespace big::mod_settings::sgg GUIComponentButton* m_debug_options_button; // +0x3F8 char m_pad_d[0x08]; eastl_vector m_options; // +0x408 - char m_pad_e[0x40]; - GUIComponent* m_description_box; // +0x460 + char m_pad_e[0x20]; // 0x420 .. 0x440 + GUIComponent* m_defaults_button; // +0x440 (bottom "Reset" prompt) + char m_pad_f[0x18]; // 0x448 .. 0x460 + GUIComponent* m_description_box; // +0x460 }; static_assert(offsetof(MiscSettingsScreen, m_page_start_index) == 0x3'44); @@ -144,5 +150,6 @@ namespace big::mod_settings::sgg static_assert(offsetof(MiscSettingsScreen, m_editor_options_button) == 0x3'C8); static_assert(offsetof(MiscSettingsScreen, m_debug_options_button) == 0x3'F8); static_assert(offsetof(MiscSettingsScreen, m_options) == 0x4'08); + static_assert(offsetof(MiscSettingsScreen, m_defaults_button) == 0x4'40); static_assert(offsetof(MiscSettingsScreen, m_description_box) == 0x4'60); } // namespace big::mod_settings::sgg From d0a7642ac60ee5bae8fb4242227b2250cffbcab3 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:54:01 +0100 Subject: [PATCH 015/100] Remove redundant Back row and clean up mod-settings menu comments --- src/hades2/mod_settings/config_api.cpp | 8 ++---- src/hades2/mod_settings/mod_settings.cpp | 36 ++++++++---------------- src/hades2/mod_settings/mod_settings.hpp | 5 ++-- 3 files changed, 16 insertions(+), 33 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 696bbaf..6170f3f 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -26,12 +26,10 @@ using namespace al; namespace big::mod_settings { // Author-declared per-setting metadata registry, populated from each mod's config.lua by - // rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Holds the widget type, - // numeric bounds, enum options, display name, ordering, and the restart-required flag that the + // rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Holds the display-name + // override, numeric bounds, enum options, ordering, and the restart-required flag that the // settings menu reads to pick and drive a widget. Only settings whose config.lua description is - // a rich table are registered; the rest fall back to type-based rendering. The restart flag - // replaces the old sjson-hook auto-detection, which could not see a mod that starts disabled - // (it registers no hooks until enabled), and never covered restarts needed for other reasons. + // a rich table are registered; the rest fall back to type-based rendering. static std::mutex g_metadata_mutex; static std::map g_setting_metadata; diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index b091bcb..a56f8b3 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -215,7 +215,7 @@ namespace big::mod_settings // "width units" where a typical medium glyph is 1.0. The menu font is variable-width, so a raw // character count looks inconsistent (a run of 'W' is far wider than a run of 'i'); budgeting by // summed glyph weight keeps the shown value a consistent WIDTH so it does not run left into the - // key label. ~30 units ~= 30 average glyphs, matching the previously tuned character cap. + // key label. ~30 units is roughly 30 average glyphs wide. static constexpr float value_display_max_width = 30.0f; // Edit-cursor blink half-period (ms): the "|" shows for this long, then hides. @@ -225,7 +225,6 @@ namespace big::mod_settings enum class RowKind { mod_entry, // opens that mod's settings - back, // returns to the mod list setting, // edits one config entry action, // a button that runs an action (e.g. Apply/Reset) }; @@ -1560,18 +1559,12 @@ namespace big::mod_settings return big::string::to_lower(key) == "enabled"; } - // Level 2: a Back row followed by one row per config entry belonging to `stem`. Boolean - // entries render as native toggle rows; other types render as a left-aligned key with a - // right-aligned, freetext-editable value (two components). A boolean "enabled" entry (if - // present) is pinned to the top; when it is off, every other setting is greyed out and - // made non-interactable. + // Level 2: one row per config entry belonging to `stem`. Boolean entries render as native toggle + // rows; other types render as a left-aligned key with a right-aligned, freetext-editable value + // (two components). A boolean "enabled" entry (if present) is pinned to the top; when it is off, + // every other setting is greyed out and made non-interactable. static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem) { - if (auto* row = make_text_row(screen, "< Back")) - { - g_rows.push_back({row, RowKind::back, stem, {}}); - } - // Gather this mod's entries. The config map is ordered alphabetically by (section, key), which // is the current appearance order and the fallback for rows without an author-declared order. struct panel_entry @@ -1895,7 +1888,6 @@ namespace big::mod_settings switch (row->kind) { case RowKind::mod_entry: confirm = "{SL} SELECT"; break; - case RowKind::back: confirm = "{SL} SELECT"; break; case RowKind::setting: if (row->entry && row->entry->type() == typeid(bool)) { @@ -1918,11 +1910,10 @@ namespace big::mod_settings } } - // Drive the Confirm prompt's visibility ourselves. Native only fades it in (OnOptionMouseOver) - // for its OWN option rows, which never fires for our custom rows, so it would otherwise stay - // invisible until first forced (e.g. by editing a field). Show it with its glyph whenever we - // have a hint, hide it when we don't. mFadeOpacity is the field the draw gate reads; native - // Update rewrites mHidden each frame, so both are set here (this runs after the original Update). + // Drive the Confirm prompt's visibility ourselves: native only fades it in (OnOptionMouseOver) + // for its OWN option rows, which never fires for our custom rows. Show it with its glyph + // whenever we have a hint, hide it when we don't. mFadeOpacity is the field the draw gate + // reads; native Update rewrites mHidden each frame, so both are set here (after the original Update). if (menu->m_confirm_button) { if (confirm.empty()) @@ -1952,7 +1943,7 @@ namespace big::mod_settings static void build_panel(MiscSettingsScreen* screen, bool instant = false) { // A rebuild frees and recreates the row components, so the cached highlighted-row pointer - // is no longer meaningful; force the description box to refresh next frame. + // is stale; force the description box to refresh next frame. g_last_description_component = nullptr; // Preserve the current scroll offset across an in-place refresh (same view/mod, e.g. @@ -2351,11 +2342,6 @@ namespace big::mod_settings g_pending_stem = matched_row.stem; g_nav_pending = true; break; - case RowKind::back: - g_pending_view = View::mod_list; - g_pending_stem.clear(); - g_nav_pending = true; - break; case RowKind::setting: { auto* entry = matched_row.entry; @@ -2529,7 +2515,7 @@ namespace big::mod_settings { if (g_view != View::mod_settings) { - return; // reset removed in the mod list; do nothing (and do not play the native reset) + return; // reset is unavailable in the mod list; do nothing (and do not play the native reset) } perform_reset(); } diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 6fd8fd5..fe50812 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -12,9 +12,8 @@ namespace big::mod_settings // Author-declared metadata for a single setting, extracted from its config.lua description // table by rom.mod_settings.load and consulted by the settings menu. Only settings whose // description is a rich table have an entry; the rest fall back to type-based rendering. Every - // field is an author-only input that cannot be inferred from the config value (the widget kind - // itself IS inferred from the value + `values`, so there is deliberately no `type` field here). - // All fields are optional (see the has_* flags). + // field is an author-only input that cannot be inferred from the config value; the widget kind + // itself is inferred from the value and `values`. All fields are optional (see the has_* flags). struct setting_metadata { std::string name; // display-name override (empty -> prettified key) From 50f3e3c86689c7d95853d7b4fd2e10c13315fbcf Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:50:34 +0100 Subject: [PATCH 016/100] Show mod descriptions and add drill-down config groups to the Mods options tab --- src/hades2/mod_settings/config_api.cpp | 7 +- src/hades2/mod_settings/mod_settings.cpp | 268 +++++++++++++++++------ 2 files changed, 208 insertions(+), 67 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 6170f3f..6d7f42b 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -438,9 +438,10 @@ namespace big::mod_settings defaults_out.emplace_back(section, key, toml_v2::toml_type_converter::convert_to_string(*default_any)); } - // Only a rich description table carries metadata; a nested value is a sub-section (its - // table holds child descriptions, not this key's metadata) and is handled by recursion. - if (vt != sol::type::table && desc.is()) + // A rich description table carries metadata. For a leaf it is the setting's metadata; for a + // nested group (a table value) it is group-level metadata (e.g. order/display_name/hidden) + // declared alongside the child descriptions. Registered under (section, key) either way. + if (desc.is()) { meta_out.push_back({section, key, extract_metadata(desc.as())}); } diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index a56f8b3..783872e 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -211,6 +211,12 @@ namespace big::mod_settings static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) static constexpr std::uint32_t rows_per_page = 10; // vanilla ItemsPerPage = 10 + // Config sections. Both rom.mod_settings.load and Chalk bind a mod's settings under the root + // "config" section; nested groups are dot-separated child sections (e.g. "config.biome_pool"). + static const std::string root_section = "config"; + // Chalk writes a placeholder entry with this key per section so empty groups persist; skip it. + static constexpr const char* section_empty_key = "..."; + // Approximate visual width budget for the right-column value (freetext + its edit caret), in // "width units" where a typical medium glyph is 1.0. The menu font is variable-width, so a raw // character count looks inconsistent (a run of 'W' is far wider than a run of 'i'); budgeting by @@ -225,6 +231,7 @@ namespace big::mod_settings enum class RowKind { mod_entry, // opens that mod's settings + group, // opens a nested config group (a child section) setting, // edits one config entry action, // a button that runs an action (e.g. Apply/Reset) }; @@ -266,6 +273,9 @@ namespace big::mod_settings bool is_enum = false; std::vector enum_values; std::vector enum_labels; + + // Group rows (RowKind::group) only: the child config section this row drills into. + std::string target_section; }; static std::vector g_rows; @@ -300,10 +310,12 @@ namespace big::mod_settings }; static View g_view = View::mod_list; - static std::string g_view_stem; // mod whose settings are shown (mod_settings view) + static std::string g_view_stem; // mod whose settings are shown (mod_settings view) + static std::string g_view_section; // config section shown within that mod (mod_settings view) static bool g_nav_pending = false; static View g_pending_view = View::mod_list; static std::string g_pending_stem; + static std::string g_pending_section; static bool g_nav_reset_to_top = false; // Reset action: force a top (non-instant) rebuild next apply_nav // Freetext edit state (number/string settings). A click enters edit mode; typed input @@ -329,6 +341,25 @@ namespace big::mod_settings return name; } + // The mod's Thunderstore manifest description, shown in the description box while its row in the + // mod list is highlighted. Empty when no loaded module matches the stem. + static std::string mod_description_from_stem(const std::string& stem) + { + if (!big::g_lua_manager) + { + return {}; + } + std::scoped_lock guard(big::g_lua_manager->m_module_lock); + for (const auto& module : big::g_lua_manager->m_modules) + { + if (module && module->guid() == stem) + { + return module->manifest().description; + } + } + return {}; + } + // Escapes the characters the game's text parser (GUIComponentTextBox::Parse) treats as markup, // so arbitrary user text - config values (e.g. Windows paths with '\'), display names and // descriptions - renders verbatim instead of being mangled. The parser reads '\' as an escape @@ -1108,7 +1139,9 @@ namespace big::mod_settings { if (auto* row = make_text_row(screen, escape_markup(display).c_str())) { - g_rows.push_back({row, RowKind::mod_entry, stem, {}}); + PanelRow pr{row, RowKind::mod_entry, stem, {}}; + pr.description = mod_description_from_stem(stem); + g_rows.push_back(std::move(pr)); } } } @@ -1559,25 +1592,32 @@ namespace big::mod_settings return big::string::to_lower(key) == "enabled"; } - // Level 2: one row per config entry belonging to `stem`. Boolean entries render as native toggle - // rows; other types render as a left-aligned key with a right-aligned, freetext-editable value - // (two components). A boolean "enabled" entry (if present) is pinned to the top; when it is off, - // every other setting is greyed out and made non-interactable. - static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem) - { - // Gather this mod's entries. The config map is ordered alphabetically by (section, key), which - // is the current appearance order and the fallback for rows without an author-declared order. - struct panel_entry - { - std::string key; - toml_v2::config_file::config_entry_base* entry = nullptr; - bool has_order = false; - double order = 0.0; - int appearance = INT_MAX; // config.lua source rank (fallback order) + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. + // Leaf entries render as setting rows (bool -> toggle, enum/bounded number -> num box, else a + // freetext value); each direct child section renders as a group row that drills into it. At the + // root section a boolean "enabled" entry (if present) is pinned to the top; when it is off, every + // other row is greyed out and made non-interactable. + static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem, const std::string& section) + { + // A menu item is either a leaf setting directly in `section`, or a direct child group (a + // nested sub-section such as "config.biome_pool" while viewing "config"). + struct panel_item + { + bool is_group = false; + std::string key; // leaf key, or the group's last path segment + toml_v2::config_file::config_entry_base* entry = nullptr; // leaf only + std::string child_section; // group only (full "config.x.y" path) + bool has_order = false; + double order = 0.0; + int appearance = INT_MAX; // config.lua source rank (fallback order) + bool is_enabled = false; // the mod's master "enabled" toggle (root section only) }; - std::vector entries; + std::vector items; + std::map groups; // child section path -> group item (keeps its min appearance) toml_v2::config_file::config_entry_base* enabled_entry = nullptr; + const std::string section_prefix = section + "."; + for (auto* cfg : toml_v2::config_file::g_config_files) { if (!cfg || cfg->m_config_file_stem_as_str != stem) @@ -1586,42 +1626,92 @@ namespace big::mod_settings } for (auto& [key, entry] : cfg->m_entries) { - if (!entry) + if (!entry || key.m_key == section_empty_key) { continue; } - panel_entry pe; - pe.key = key.m_key; - pe.entry = entry.get(); - pe.appearance = get_setting_appearance_order(stem, key.m_section, key.m_key); - if (const auto meta = get_setting_metadata(stem, key.m_section, key.m_key); meta && meta->has_order) + + // The mod's master switch lives in the root section; track it whatever section is + // being shown, so nested rows are greyed when the mod is disabled. + if (!enabled_entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) + { + enabled_entry = entry.get(); + } + + if (key.m_section == section) { - pe.has_order = true; - pe.order = meta->order; + panel_item it; + it.key = key.m_key; + it.entry = entry.get(); + it.appearance = get_setting_appearance_order(stem, key.m_section, key.m_key); + if (const auto meta = get_setting_metadata(stem, key.m_section, key.m_key); meta && meta->has_order) + { + it.has_order = true; + it.order = meta->order; + } + items.push_back(std::move(it)); } - entries.push_back(std::move(pe)); - if (!enabled_entry && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) + else if (key.m_section.rfind(section_prefix, 0) == 0) { - enabled_entry = entry.get(); + // A descendant section: the direct child under `section` is the first path segment + // after the prefix. Collapse its whole subtree into one group row, ranked by its + // earliest-defined descendant. + const std::string rest = key.m_section.substr(section_prefix.size()); + const std::string child = rest.substr(0, rest.find('.')); + const std::string child_path = section_prefix + child; + const int app = get_setting_appearance_order(stem, key.m_section, key.m_key); + const auto git = groups.find(child_path); + if (git == groups.end()) + { + panel_item g; + g.is_group = true; + g.key = child; + g.child_section = child_path; + g.appearance = app; + if (const auto meta = get_setting_metadata(stem, section, child); meta && meta->has_order) + { + g.has_order = true; + g.order = meta->order; + } + groups.emplace(child_path, std::move(g)); + } + else if (app < git->second.appearance) + { + git->second.appearance = app; + } } } } - // Row order: the master "enabled" toggle is always pinned to the top; then rows with an - // author-declared `order` (ascending); then rows with no `order`. Within each of those two - // groups, and to break equal `order` values, rows fall back to their config.lua source order - // (appearance rank). stable_sort keeps any remaining ties in the config-map order. - std::stable_sort(entries.begin(), - entries.end(), - [&](const panel_entry& a, const panel_entry& b) + for (auto& kv : groups) + { + items.push_back(std::move(kv.second)); + } + + const bool mod_enabled = !enabled_entry || enabled_entry->get_value_base(); + if (section == root_section && enabled_entry) + { + for (auto& it : items) + { + if (it.entry == enabled_entry) + { + it.is_enabled = true; + } + } + } + + // Row order: the master "enabled" toggle is pinned to the top; then rows with an author + // `order` (ascending); then the rest. Ties and absent order fall back to config.lua source + // order (a group's rank is its earliest-defined descendant's). + std::stable_sort(items.begin(), + items.end(), + [](const panel_item& a, const panel_item& b) { - const bool a_enabled = (a.entry == enabled_entry); - const bool b_enabled = (b.entry == enabled_entry); - if (a_enabled != b_enabled) + if (a.is_enabled != b.is_enabled) { - return a_enabled; // enabled toggle first + return a.is_enabled; // enabled toggle first } - if (a_enabled) + if (a.is_enabled) { return false; // only one enabled entry exists } @@ -1633,19 +1723,44 @@ namespace big::mod_settings { return a.order < b.order; } + if (!a.has_order && a.is_group != b.is_group) + { + return a.is_group; // with no explicit order, groups are pinned above settings + } return a.appearance < b.appearance; // equal/absent order -> config.lua source order }); - const bool mod_enabled = !enabled_entry || enabled_entry->get_value_base(); - - for (const auto& row_src : entries) + for (const auto& it : items) { - const std::string& key = row_src.key; - auto* entry = row_src.entry; - - const bool is_enabled_row = (entry == enabled_entry); + const bool is_enabled_row = it.is_enabled; const bool disabled = !is_enabled_row && !mod_enabled; + // A nested group drills into its child section when clicked/activated. + if (it.is_group) + { + const auto gmeta = get_setting_metadata(stem, section, it.key); + if (gmeta && gmeta->hidden) + { + continue; + } + const std::string glabel = escape_markup((gmeta && !gmeta->name.empty()) ? gmeta->name : key_to_display(it.key)); + if (auto* row = make_text_row(screen, glabel.c_str(), disabled)) + { + PanelRow pr{row, RowKind::group, stem, {}}; + pr.disabled = disabled; + pr.target_section = it.child_section; + if (gmeta) + { + pr.description = gmeta->description; + } + g_rows.push_back(std::move(pr)); + } + continue; + } + + const std::string& key = it.key; + auto* entry = it.entry; + // Author metadata (if any) can rename the row, hide it, and (later) pick its widget. const auto meta = get_setting_metadata(stem, entry->m_definition.m_section, entry->m_definition.m_key); if (meta && meta->hidden) @@ -1888,6 +2003,7 @@ namespace big::mod_settings switch (row->kind) { case RowKind::mod_entry: confirm = "{SL} SELECT"; break; + case RowKind::group: confirm = "{SL} SELECT"; break; case RowKind::setting: if (row->entry && row->entry->type() == typeid(bool)) { @@ -1966,7 +2082,7 @@ namespace big::mod_settings if (g_view == View::mod_settings && !g_view_stem.empty()) { - build_mod_settings(screen, g_view_stem); + build_mod_settings(screen, g_view_stem, g_view_section.empty() ? root_section : g_view_section); } else { @@ -2019,11 +2135,12 @@ namespace big::mod_settings // A Reset forces a top (non-instant) rebuild even though the view is unchanged, so the // restored rows and the scrollbar stay in sync - an in-place rebuild that preserves a // scrolled position would leave the stale page-1 rows visible (see the scroll-model notes). - const bool instant = !g_nav_reset_to_top && (g_pending_view == g_view) && (g_pending_stem == g_view_stem); + const bool instant = !g_nav_reset_to_top && (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); g_nav_reset_to_top = false; - g_view = g_pending_view; - g_view_stem = g_pending_stem; + g_view = g_pending_view; + g_view_stem = g_pending_stem; + g_view_section = g_pending_section; build_panel(screen, instant); } @@ -2076,6 +2193,7 @@ namespace big::mod_settings { g_pending_view = g_view; g_pending_stem = g_view_stem; + g_pending_section = g_view_section; g_nav_pending = true; g_nav_reset_to_top = true; } @@ -2193,6 +2311,8 @@ namespace big::mod_settings g_rows.clear(); g_view = View::mod_list; g_view_stem.clear(); + g_view_section.clear(); + g_pending_section.clear(); g_nav_pending = false; g_nav_reset_to_top = false; g_restart_required = false; @@ -2233,6 +2353,7 @@ namespace big::mod_settings // via the Update hook, not by re-entering the category. g_view = View::mod_list; g_view_stem.clear(); + g_view_section.clear(); g_nav_pending = false; exit_edit_mode(); build_panel(screen); @@ -2338,9 +2459,16 @@ namespace big::mod_settings switch (matched_row.kind) { case RowKind::mod_entry: - g_pending_view = View::mod_settings; - g_pending_stem = matched_row.stem; - g_nav_pending = true; + g_pending_view = View::mod_settings; + g_pending_stem = matched_row.stem; + g_pending_section = root_section; + g_nav_pending = true; + break; + case RowKind::group: + g_pending_view = View::mod_settings; + g_pending_stem = matched_row.stem; + g_pending_section = matched_row.target_section; + g_nav_pending = true; break; case RowKind::setting: { @@ -2366,9 +2494,10 @@ namespace big::mod_settings // are greyed out, so rebuild the settings view on the next Update. if (matched_row.is_enabled_toggle) { - g_pending_view = View::mod_settings; - g_pending_stem = matched_row.stem; - g_nav_pending = true; + g_pending_view = View::mod_settings; + g_pending_stem = matched_row.stem; + g_pending_section = g_view_section; + g_nav_pending = true; } } else if (entry) @@ -2475,17 +2604,28 @@ namespace big::mod_settings // forced. If the native dialog cannot be shown, the MessageBox fallback closes the game anyway. static void hook_MiscSettingsScreen_ExitScreen(void* self) { - // Inside a mod's settings, Esc / controller B / the on-screen Back button navigates back to - // the mod list instead of closing the whole options screen (mirrors the native category - // drill-down, where the same button reads "Back"). Only the mod-list view actually closes. + // Inside a mod's settings, Esc / controller B / the on-screen Back button steps up one level: + // a nested group returns to its parent section, and the root returns to the mod list. Only the + // mod-list view actually closes the options screen. auto* screen = static_cast(self); const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); if (on_mods_tab && g_view == View::mod_settings) { - g_pending_view = View::mod_list; - g_pending_stem.clear(); + const auto dot = g_view_section.rfind('.'); + if (dot != std::string::npos) + { + g_pending_view = View::mod_settings; + g_pending_stem = g_view_stem; + g_pending_section = g_view_section.substr(0, dot); + } + else + { + g_pending_view = View::mod_list; + g_pending_stem.clear(); + g_pending_section.clear(); + } g_nav_pending = true; - return; // veto the close; apply_nav swaps back to the mod list next Update + return; // veto the close; apply_nav applies the new view next Update } if (g_restart_required && !g_restart_prompt_shown) From 1982c8b51ba27dfa38602fdb544bc962c69f24f4 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:25:37 +0100 Subject: [PATCH 017/100] Match native option-row fade ease for Mods-tab scroll and view transitions --- src/hades2/mod_settings/mod_settings.cpp | 46 ++++++++++++++++++++---- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 783872e..47b20c8 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -76,7 +76,14 @@ namespace big::mod_settings static constexpr std::size_t def_sel_text_red = 0x1'28; // mSelectedTextRed (float) static constexpr std::size_t def_sel_text_green = 0x1'2C; // mSelectedTextGreen (float) static constexpr std::size_t def_sel_text_blue = 0x1'30; // mSelectedTextBlue (float) - static constexpr std::size_t def_spacing = 0x1'5C; // mSpacing (float) row pitch, read by UpdateScrollState + static constexpr std::size_t def_spacing = 0x1'5C; // mSpacing (float) row pitch, read by UpdateScrollState + static constexpr std::size_t def_fade_speed = 0x2'1C; // mFadeSpeed (float) opacity ease rate (component +0x2C4) + + // Opacity ease rate applied to every row so all row types fade at one uniform speed. The native + // OptionToggleButton / OptionNumBox templates use 10.0; CategoryOptionsButton (our text/value/ + // group rows) declares none, so we set it explicitly. GUIComponent::Update moves mFadeOpacity + // toward mFadeTarget by dt * mFadeSpeed each frame, so this drives the fade timing. + static constexpr float row_fade_speed = 10.0f; // Native sgg::MessageDialog (the single-button message box the game shows in the MAIN MENU for // save/file errors, ShellText SaveErrorPC/FileAccessErrorPC). Unlike the Lua screen system it @@ -613,6 +620,8 @@ namespace big::mod_settings row->m_location_x = row_location_x; row->m_fade_opacity = 0.0f; + // Uniform opacity ease rate so every row type fades at the same native speed (see row_fade_speed). + *reinterpret_cast(reinterpret_cast(row) + component_def_offset + def_fade_speed) = row_fade_speed; } // Shows the on or off toggle graphic for a toggle row. The OptionToggleButton template @@ -1852,6 +1861,28 @@ namespace big::mod_settings } } + // Matches the native category-switch transition: the incoming page fades in and there is no + // fade-out crossover. Native UpdateScrollState sets each on-page row's mFadeTarget to 1 and each + // off-page row's to 0, and GUIComponent::Update (driven by MenuScreen::Update, which the original + // runs before this) eases mFadeOpacity toward the target at dt * mFadeSpeed - so on-page rows are + // left entirely to the native ease. We only force off-page rows fully transparent so a row leaving + // the page vanishes at once instead of fading out on top of the incoming page. Rows are in + // m_options / g_rows order, so row i is on the current page when start <= i < start + rows_per_page. + static void sync_scroll_fade(MiscSettingsScreen* screen) + { + const std::size_t first = screen->m_page_start_index; + const std::size_t last = first + rows_per_page; + for (std::size_t i = 0; i < g_rows.size(); ++i) + { + auto* comp = g_rows[i].component; + if (comp && !(i >= first && i < last)) + { + comp->m_fade_opacity = 0.0f; + comp->m_fade_target = 0.0f; + } + } + } + // Value displays are not in mOptions, so the engine's scroll pass does not lay them out. // Mirror each value component onto its key row's current position and fade so the right // column tracks scrolling and fade-in/out. @@ -2103,15 +2134,13 @@ namespace big::mod_settings screen->m_options_per_page = rows_per_page; if (g_update_scroll) { - g_update_scroll(screen); + g_update_scroll(screen); // sets each row's mFadeTarget: 1 on-page, 0 off-page } - // For an in-place refresh (e.g. toggling the mod's "enabled" switch, which only - // changes greying) snap each row straight to its final visibility so the panel does - // not flash a fade-out/in. UpdateScrollState set the on-page rows' fade target to 1 - // and off-page rows' to 0, so copying target->opacity gives the settled look at once. if (instant) { + // In-place refresh (e.g. toggling the mod's "enabled" switch, which only changes greying): + // snap each row straight to its final visibility so the panel does not flash a fade. for (const auto& row : g_rows) { if (row.component) @@ -2120,6 +2149,10 @@ namespace big::mod_settings } } } + // A view change leaves the freshly built rows at mFadeOpacity 0 (finalize_row); the native + // ease (GUIComponent::Update) then fades the on-page rows in toward mFadeTarget == 1, matching + // the game's own category-switch transition. Off-page rows are held transparent in + // sync_scroll_fade. // Value displays are not laid out by the scroll pass; place them on their key rows now. sync_value_columns(); @@ -2555,6 +2588,7 @@ namespace big::mod_settings // row's description in the native description box. if (on_mods_tab) { + sync_scroll_fade(screen); sync_value_columns(); sync_description_box(screen); } From fc9d0eaa9c65c27c61e182ebe48a335051c1c5d0 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:36:34 +0100 Subject: [PATCH 018/100] Split CamelCase in Mods-tab mod names using the setting-key friendly-name logic --- src/hades2/mod_settings/mod_settings.cpp | 26 ++++++++++++++---------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 47b20c8..71b6c79 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -337,15 +337,18 @@ namespace big::mod_settings static bool g_edit_confirm = false; static bool g_edit_cancel = false; - // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the - // author (up to the first '-') and shows the mod name with '_' replaced by spaces. - // "SGG_Modding-Chalk" -> "Chalk"; "NikkelM-Zagreus_Journey" -> "Zagreus Journey". + // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the author (up to + // the first '-') and runs the mod name through key_to_display, so '_' becomes a space and + // camelCase / PascalCase word boundaries are split - the same friendly-name logic used for + // setting keys. "SGG_Modding-Chalk" -> "Chalk"; "NikkelM-Zagreus_Journey" -> "Zagreus Journey"; + // "zerp-DreamDiveTweaks" -> "Dream Dive Tweaks". + static std::string key_to_display(const std::string& key); // shared friendly-name logic, defined below + static std::string display_name_from_stem(const std::string& stem) { - const auto dash = stem.find('-'); - std::string name = (dash == std::string::npos) ? stem : stem.substr(dash + 1); - std::replace(name.begin(), name.end(), '_', ' '); - return name; + const auto dash = stem.find('-'); + const std::string name = (dash == std::string::npos) ? stem : stem.substr(dash + 1); + return key_to_display(name); } // The mod's Thunderstore manifest description, shown in the description box while its row in the @@ -1155,10 +1158,11 @@ namespace big::mod_settings } } - // Setting key as a display string: underscores become spaces, and camelCase / PascalCase word - // boundaries are split ("z_ThisConfigKey" -> "z This Config Key"). An acronym run splits before - // its final capital when that capital starts a lowercase word ("HTTPServer" -> "HTTP Server"). - // Authors can override this entirely with `display_name`. + // Turns an identifier into a friendly display string: underscores become spaces, and camelCase / + // PascalCase word boundaries are split ("z_ThisConfigKey" -> "z This Config Key"). An acronym run + // splits before its final capital when that capital starts a lowercase word ("HTTPServer" -> + // "HTTP Server"). Used for both setting keys and mod names (via display_name_from_stem). Authors + // can override this entirely with `display_name`. static std::string key_to_display(const std::string& key) { const auto is_upper = [](char c) From 7cdc39af81d1fbc6da0c1dca478369161943329d Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:04:55 +0100 Subject: [PATCH 019/100] Hide Hell2Modding-General from the Mods-tab list --- src/hades2/mod_settings/mod_settings.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 71b6c79..79b4686 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1128,6 +1128,11 @@ namespace big::mod_settings { continue; } + // H2M's own framework config is not a mod the user configures here. + if (cfg->m_config_file_stem_as_str == "Hell2Modding-Hell2Modding-General") + { + continue; + } if (std::find(stems.begin(), stems.end(), cfg->m_config_file_stem_as_str) == stems.end()) { stems.push_back(cfg->m_config_file_stem_as_str); From e74b7f97d38161bf687acb6728fc1c5b67a427a9 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:35:18 +0100 Subject: [PATCH 020/100] Render bounded-number settings as native sliders with percentage display options --- src/hades2/mod_settings/config_api.cpp | 11 + src/hades2/mod_settings/mod_settings.cpp | 332 +++++++++++++++++++++-- src/hades2/mod_settings/mod_settings.hpp | 6 + 3 files changed, 331 insertions(+), 18 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 6d7f42b..6bae33d 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -279,6 +279,17 @@ namespace big::mod_settings m.freetext = freetext_field.as(); } + sol::object show_pct_field = desc["show_as_percentage"]; + if (show_pct_field.is()) + { + m.show_as_percentage = show_pct_field.as(); + } + sol::object is_pct_field = desc["is_percentage"]; + if (is_pct_field.is()) + { + m.is_percentage = is_pct_field.as(); + } + m.restart_required = description_requires_restart(desc); return m; diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 79b4686..511643d 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -137,6 +137,26 @@ namespace big::mod_settings static constexpr std::size_t numbox_right_arrow_offset = 0x5'A0; // mRightArrow (GUIComponentAnimation*) static constexpr std::size_t numbox_label_text_offset = 0x5'A8; // mTextBox (GUIComponentTextBox*, the label) static constexpr std::size_t numbox_sizeof = 0x5'D0; + + // sgg::GUIComponentSlider (the horizontal drag bar used by the audio-volume options). DIA-validated + // on the current Ship build; sizeof 0x5B0, derives directly from GUIComponent. It is a pure 0..1 + // fraction control (no min/max/step fields) - the value is mFraction and the fill graphic redraws + // from it. The game has no factory for it (DoShowCategory hand-rolls the allocation + the four + // sub-components), so make_slider_row replicates that construction. + static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'48; // ??_7GUIComponentSlider@sgg@@6B@ (off the anchor) + static constexpr std::size_t slider_sizeof = 0x5'B0; + static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking / mFill) + static constexpr std::size_t textbox_sizeof = 0x6'C0; // sgg::GUIComponentTextBox (mLabel / mValueTextBox) + static constexpr std::size_t menu_screen_container_offset = 0x50; // owner + 0x50 = the IGUIComponentContainer base + static constexpr std::size_t slider_parent_offset = 0x3'90; // GUIComponent::mParentContainer (SetParent writes here) + static constexpr std::size_t slider_owner_offset = 0x5'40; // mOwner (MenuScreen*) + static constexpr std::size_t slider_on_changed_offset = 0x5'58; // mOnValueChanged (vector begin/end/cap, 3 qwords) + static constexpr std::size_t slider_backing_offset = 0x5'70; // mBacking (GUIComponentImage*, bar background) + static constexpr std::size_t slider_fill_offset = 0x5'78; // mFill (GUIComponentImage*, progress fill) + static constexpr std::size_t slider_label_offset = 0x5'90; // mLabel (GUIComponentTextBox*, left label) + static constexpr std::size_t slider_value_text_offset = 0x5'98; // mValueTextBox (GUIComponentTextBox*, right value) + static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) + // Scalar deleting destructor slot in the GUIComponent vtable. Called with flags=0 it destructs // and frees any owned sub-components without the final operator delete, so we then _aligned_free. static constexpr std::size_t vtable_deleting_dtor_offset = 0x1'88; @@ -160,6 +180,11 @@ namespace big::mod_settings using numbox_factory_fn = void* (*)(const char* file, int line, const char* tag, void** screen); using numbox_set_range_fn = void (*)(void* num_box, float min, float max); using numbox_set_value_fn = void (*)(void* num_box, float value, bool notify); + // GUIComponent-derived constructors take the initial location as a Vec2 passed by value (packed + // into a single 64-bit register); 0 is the origin. Used to hand-build a slider and its sub-components. + using gui_component_ctor_fn = void (*)(void* self, std::uint64_t location_packed); + using slider_defaults_fn = void (*)(void* slider); + using slider_set_fraction_fn = void (*)(void* slider, float fraction, bool notify); // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -189,6 +214,12 @@ namespace big::mod_settings static numbox_factory_fn g_numbox_factory = nullptr; static numbox_set_range_fn g_numbox_set_range = nullptr; static numbox_set_value_fn g_numbox_set_value = nullptr; + static gui_component_ctor_fn g_gui_component_ctor = nullptr; + static gui_component_ctor_fn g_image_ctor = nullptr; + static gui_component_ctor_fn g_textbox_ctor = nullptr; + static slider_defaults_fn g_slider_defaults = nullptr; + static slider_set_fraction_fn g_slider_set_fraction = nullptr; + static std::uintptr_t g_slider_vtable = 0; // runtime slider vftable address (anchor_base + slider_vtable_rva) // Set true by register_hooks only once every engine symbol, RVA and offset the Mods tab needs has // resolved for the running game build. While false no hooks are installed and the tab is absent; @@ -213,10 +244,11 @@ namespace big::mod_settings static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column static constexpr float value_text_offset_x = 15.0f; // right-justify the value; right edge aligns with the toggle's static constexpr float numbox_location_x = 1365.0f; // native OptionNumBox X (box + arrows clear the scrollbar) - static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) - static constexpr float row_base_y = 300.0f; // first row's Y - matches the vanilla option templates - static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) - static constexpr std::uint32_t rows_per_page = 10; // vanilla ItemsPerPage = 10 + static constexpr float slider_location_x = 1330.0f; // native OptionSlider X (bar + value clear the scrollbar; the template's label offset puts the name in the option-name column) + static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) + static constexpr float row_base_y = 300.0f; // first row's Y - matches the vanilla option templates + static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) + static constexpr std::uint32_t rows_per_page = 10; // vanilla ItemsPerPage = 10 // Config sections. Both rom.mod_settings.load and Chalk bind a mod's settings under the root // "config" section; nested groups are dot-separated child sections (e.g. "config.biome_pool"). @@ -265,14 +297,21 @@ namespace big::mod_settings // left-column key). Not in mOptions; positioned to follow `component` each frame. GUIComponent* value_component = nullptr; - // Numeric stepper (bounded number setting: metadata has both min and max). Left/right - // adjusts the value by `stepper_step`, clamped to [stepper_min, stepper_max]; a mouse - // click increments and wraps. When false, a numeric setting uses the freetext editor. + // Bounded number setting (metadata has both min and max). Rendered as a native slider (drag + // bar) spanning [stepper_min, stepper_max] and snapped to stepper_step; is_slider marks that. + // If the slider cannot be built it falls back to a number-box stepper (is_stepper) that steps + // by stepper_step. A number without bounds uses the freetext editor instead. + bool is_slider = false; bool is_stepper = false; double stepper_min = 0.0; double stepper_max = 0.0; double stepper_step = 1.0; + // Number-display options (slider value text): is_percentage shows a 0..1 value as 0..100 and + // appends "%"; show_as_percentage only appends "%". + bool show_as_percentage = false; + bool is_percentage = false; + // Enum cycler (metadata has `values`). Rendered as a native number box over the index // 0..labels-1 whose value text is overridden to the label (like the game's own enum // options). `enum_values` are the serialized config values, `enum_labels` the parallel @@ -1035,6 +1074,165 @@ namespace big::mod_settings return nb; } + // Formats a numeric setting value for display. is_pct shows a 0..1 value as 0..100 and appends "%"; + // show_as_pct only appends "%" (no scaling). Setting both is the same as is_pct alone. The value is + // rounded to the display step's precision so scaling by 100 does not surface floating-point noise, + // then trailing zeros are trimmed ("53", "0.5", "50%"). + static std::string format_setting_display(double value, bool show_as_pct, bool is_pct, double step) + { + double shown = is_pct ? value * 100.0 : value; + const double disp_step = is_pct ? step * 100.0 : step; + + // Decimal places implied by the display step (0.01 -> 2, 1 -> 0), capped for sanity. + int decimals = 0; + if (disp_step > 0.0) + { + double s = disp_step; + while (decimals < 6 && std::abs(s - std::round(s)) > 1e-9) + { + s *= 10.0; + ++decimals; + } + } + const double scale = std::pow(10.0, decimals); + shown = std::round(shown * scale) / scale; + + std::string out = std::to_string(shown); // fixed 6-decimal form, e.g. "53.000000" + if (out.find('.') != std::string::npos) + { + const std::size_t last = out.find_last_not_of('0'); + out.erase((out[last] == '.') ? last : last + 1); // drop trailing zeros (and a bare '.') + } + if (show_as_pct || is_pct) + { + out += "%"; + } + return out; + } + + // Sets the slider's right-hand value text (mValueTextBox). The native drag handler rewrites this to + // a percentage on every change, so we re-apply the setting's real value after each user edit. + static void set_slider_value_text(GUIComponent* slider, const char* text) + { + if (!g_show_text || !slider) + { + return; + } + if (void* value_tb = *reinterpret_cast(reinterpret_cast(slider) + slider_value_text_offset)) + { + g_show_text(value_tb, text); + } + } + + // Builds a native sgg::GUIComponentSlider row - the horizontal drag bar the audio-volume options + // use - for a bounded numeric setting. The slider stores a normalized 0..1 fraction; we map the + // setting's [min,max] onto it and snap drags to `step` in the SetFraction hook. The engine has no + // factory for this type, so this replicates the construction DoShowCategory performs for the volume + // rows: allocate the block, run the base GUIComponent constructor, install the slider vtable, zero + // the fields Defaults leaves untouched, run Defaults, then allocate and construct the four owned + // sub-components (bar background, fill, label, value text). Named "OptionSlider" so + // ApplyDataToComponent applies the matching sjson template (bar graphics, colours, FadeSpeed, label + // styling). Teardown mirrors the num-box: destroy_rows routes it through the vtable deleting + // destructor (which frees the sub-components) then _aligned_free. Returns null if any required engine + // helper is missing, in which case the caller falls back to a number-box stepper. + static GUIComponent* make_slider_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool show_as_pct, bool is_pct, bool disabled) + { + if (!g_gui_component_ctor || !g_image_ctor || !g_textbox_ctor || !g_slider_defaults || !g_slider_set_fraction || !g_slider_vtable || !g_apply_data || !g_show_text) + { + return nullptr; + } + + char* s = static_cast(_aligned_malloc(slider_sizeof, 8)); + if (!s) + { + return nullptr; + } + std::memset(s, 0, slider_sizeof); + + // Base GUIComponent constructor (location passed by value; 0 = origin, overridden below by + // ApplyDataToComponent / finalize_row), then install the slider vtable over the base one. + g_gui_component_ctor(s, 0); + *reinterpret_cast(s) = g_slider_vtable; + + // Defaults does not initialise mOnValueChanged or mValueTextBox, so zero them (the block is + // freshly malloc'd) before Defaults runs and before anything reads them. + std::memset(s + slider_on_changed_offset, 0, 3 * sizeof(void*)); + *reinterpret_cast(s + slider_label_offset) = nullptr; + *reinterpret_cast(s + slider_value_text_offset) = nullptr; + + g_slider_defaults(s); + *reinterpret_cast(s + slider_owner_offset) = screen; + + // Four owned sub-components, each allocated then constructed at the origin (as the game does): + // two images (bar background + fill) and two text boxes (left label + right value). + char* backing = static_cast(_aligned_malloc(image_sizeof, 8)); + char* fill = static_cast(_aligned_malloc(image_sizeof, 8)); + char* lbl = static_cast(_aligned_malloc(textbox_sizeof, 8)); + char* val = static_cast(_aligned_malloc(textbox_sizeof, 8)); + if (!backing || !fill || !lbl || !val) + { + _aligned_free(backing); + _aligned_free(fill); + _aligned_free(lbl); + _aligned_free(val); + _aligned_free(s); + return nullptr; + } + g_image_ctor(backing, 0); + g_image_ctor(fill, 0); + g_textbox_ctor(lbl, 0); + g_textbox_ctor(val, 0); + *reinterpret_cast(s + slider_backing_offset) = backing; + *reinterpret_cast(s + slider_fill_offset) = fill; + *reinterpret_cast(s + slider_label_offset) = lbl; + *reinterpret_cast(s + slider_value_text_offset) = val; + + // Parent container, matching DoShowCategory. SetParent is a plain setter (writes + // mParentContainer), so a direct write is equivalent and avoids a vtable call. + *reinterpret_cast(s + slider_parent_offset) = reinterpret_cast(screen) + menu_screen_container_offset; + + // Name the slider and its value box so ApplyDataToComponent applies the OptionSlider / + // OptionSliderValueText templates (bar graphics, colours, FadeSpeed and the label styling). + set_sso_string(s + gui_component_name_offset, "OptionSlider"); + set_sso_string(val + gui_component_name_offset, "OptionSliderValueText"); + + if (disabled) + { + set_def_text_grey(reinterpret_cast(s)); // grey before ApplyData so it reaches the text boxes + } + + g_apply_data(reinterpret_cast(screen), reinterpret_cast(s)); + + // Override the template's row grid (Y=300, Spacing=45) so the bar lines up with the other rows. + { + char* def = s + component_def_offset; + *reinterpret_cast(def + def_y) = row_base_y; + *reinterpret_cast(def + def_spacing) = row_pitch; + } + + if (void* label_tb = *reinterpret_cast(s + slider_label_offset)) + { + g_show_text(label_tb, label); + } + + // Paint the starting value: map [min,max] -> 0..1 and set the fraction without notifying (so the + // SetFraction hook does not treat it as a user edit), then show the real value (not a percentage). + const double range = max_v - min_v; + const float frac = (range > 0.0) ? static_cast((initial - min_v) / range) : 0.0f; + g_slider_set_fraction(s, frac, false); + set_slider_value_text(reinterpret_cast(s), + format_setting_display(initial, show_as_pct, is_pct, step_v).c_str()); + + if (disabled) + { + reinterpret_cast(s)->m_is_useable = false; // not focusable / not draggable + } + + finalize_row(screen, reinterpret_cast(s)); + reinterpret_cast(s)->m_location_x = slider_location_x; // override finalize_row's default + return reinterpret_cast(s); + } + // Removes the first pointer equal to `value` from an eastl vector by shifting the tail // down in place - the same unlink the engine's DoShowCategory performs. No-op if not // present; the backing storage is left owned by the vector. @@ -1060,7 +1258,7 @@ namespace big::mod_settings { auto* menu = reinterpret_cast(screen); - auto unlink_and_free = [&](GUIComponent* comp, bool in_options, bool is_numbox) + auto unlink_and_free = [&](GUIComponent* comp, bool in_options, bool owns_subcomponents) { if (!comp) { @@ -1093,11 +1291,12 @@ namespace big::mod_settings vector_erase(screen->m_options, comp); } - if (is_numbox) + if (owns_subcomponents) { - // GUIComponentNumBox is not a GUIComponentButton; destruct it through its own vtable - // so its five sub-components (box/label/value/arrows) are freed too. flags=0 destructs - // without the final operator delete, so we still _aligned_free the block ourselves. + // The num-box and slider are not GUIComponentButtons; destruct through the component's + // own vtable so its owned sub-components (num-box: box/label/value/arrows; slider: + // background/fill/label/value) are freed too. flags=0 destructs without the final + // operator delete, so we still _aligned_free the block ourselves. void** vtbl = *reinterpret_cast(comp); auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset / sizeof(void*)]); dtor(comp, 0); @@ -1111,7 +1310,7 @@ namespace big::mod_settings for (const auto& row : g_rows) { - unlink_and_free(row.component, true, row.is_stepper || row.is_enum); + unlink_and_free(row.component, true, row.is_stepper || row.is_enum || row.is_slider); unlink_and_free(row.value_component, false, false); } @@ -1819,6 +2018,7 @@ namespace big::mod_settings GUIComponent* row = nullptr; GUIComponent* value = nullptr; + bool built_slider = false; if (entry->type() == typeid(bool)) { row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), disabled); @@ -1829,7 +2029,17 @@ namespace big::mod_settings } else if (is_stepper) { - row = make_numbox_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), disabled); + // Bounded number: a slider (drag bar) like the audio-volume rows, snapped to step. Fall + // back to a number-box stepper if the slider cannot be built on this game build. + row = make_slider_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), meta->show_as_percentage, meta->is_percentage, disabled); + if (row) + { + built_slider = true; + } + else + { + row = make_numbox_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), disabled); + } } else { @@ -1859,10 +2069,13 @@ namespace big::mod_settings } else if (is_stepper) { - pr.is_stepper = true; - pr.stepper_min = meta->min; - pr.stepper_max = meta->max; - pr.stepper_step = step; + pr.is_slider = built_slider; + pr.is_stepper = !built_slider; + pr.stepper_min = meta->min; + pr.stepper_max = meta->max; + pr.stepper_step = step; + pr.show_as_percentage = meta->show_as_percentage; + pr.is_percentage = meta->is_percentage; } g_rows.push_back(pr); @@ -2053,6 +2266,10 @@ namespace big::mod_settings { confirm = "{SL} SET"; } + else if (row->is_slider) + { + confirm = "{SL} SET"; // matches the base-game volume sliders' prompt + } else if (row->is_stepper) { confirm = "{SL} SELECT"; @@ -2463,6 +2680,65 @@ namespace big::mod_settings note_change_if_restart_required(row->entry, row->entry->get_serialized_value()); } + // Value-change hook for our native slider rows. GUIComponentSlider::SetFraction is called with + // notify=true on every user drag / left-right adjust (the native handler also rewrites the value + // text to a percentage). We run the original, then, for our rows, snap the post-clamp fraction to + // the setting's step, persist the mapped [min,max] value on change, and restore the real value text. + // notify is false only for our own initial paint (make_slider_row), so filtering on it skips that. + // Fires for the native audio sliders too, hence the find_row filter. + static void hook_GUIComponentSlider_SetFraction(void* self, float fraction, bool notify) + { + big::g_hooking->get_original()(self, fraction, notify); + + if (!notify || !self) + { + return; + } + + PanelRow* row = find_row(reinterpret_cast(self)); + if (!row || !row->is_slider || !row->entry || row->disabled) + { + return; + } + + const double min_v = row->stepper_min; + const double max_v = row->stepper_max; + const double step_v = row->stepper_step; + const double range = max_v - min_v; + + // Post-clamp fraction the original just wrote, mapped back to the value, snapped to step. + const float f = *reinterpret_cast(reinterpret_cast(self) + slider_fraction_offset); + double v = min_v + static_cast(f) * range; + if (step_v > 0.0 && range > 0.0) + { + v = min_v + std::round((v - min_v) / step_v) * step_v; + } + if (v < min_v) + { + v = min_v; + } + else if (v > max_v) + { + v = max_v; + } + + // Rest the bar on the snapped position by writing mFraction straight back. Calling SetFraction + // again would re-enter this hook, so we set the field directly (the fill redraws from it). + *reinterpret_cast(reinterpret_cast(self) + slider_fraction_offset) = (range > 0.0) ? static_cast((v - min_v) / range) : 0.0f; + + if (row->entry->get_value_base() != v) + { + capture_restart_baseline(row->entry); + row->entry->set_value_base(v); // auto-saves via on_setting_changed + note_change_if_restart_required(row->entry, row->entry->get_serialized_value()); + } + + // Restore the real value in place of the percentage the original wrote (applying the setting's + // own percentage-display options). + set_slider_value_text(reinterpret_cast(self), + format_setting_display(v, row->show_as_percentage, row->is_percentage, step_v).c_str()); + } + // Button-click hook. GUIComponentButton overrides GUIComponent::OnClicked (vtable slot // +0x100, the engine's terminal-click), so this is where our button rows' clicks land. // For our rows the engine returns false (they have no bound activate function) but still @@ -2761,6 +3037,18 @@ namespace big::mod_settings g_button_dtor = big::hades2_symbol_to_address["sgg::GUIComponentButton::~GUIComponentButton"].as_func(); g_disable = big::hades2_symbol_to_address["sgg::GUIComponentButton::Disable"].as_func(); + // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the + // number-box stepper). The engine has no slider factory, so a slider is hand-built from the base + // GUIComponent / image / text-box constructors and Defaults - all resolved by name here. + // SetFraction is both the initial set and the drag hook (installed below); the slider vtable is + // addressed by RVA off the anchor once the build is verified. + g_gui_component_ctor = big::hades2_symbol_to_address["sgg::GUIComponent::GUIComponent"].as_func(); + g_image_ctor = big::hades2_symbol_to_address["sgg::GUIComponentImage::GUIComponentImage"].as_func(); + g_textbox_ctor = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GUIComponentTextBox"].as_func(); + g_slider_defaults = big::hades2_symbol_to_address["sgg::GUIComponentSlider::Defaults"].as_func(); + const auto slider_set_fraction = big::hades2_symbol_to_address["sgg::GUIComponentSlider::SetFraction"]; + g_slider_set_fraction = slider_set_fraction.as_func(); + // The num-box factory (a template instantiation) and the restart-dialog ctor / AddScreen // overloads cannot be picked by name from the PDB, so they are addressed by hardcoded RVA off // the button-ctor anchor. Those RVAs - and every struct offset this feature uses - are valid @@ -2805,6 +3093,7 @@ namespace big::mod_settings g_message_dialog_ctor = reinterpret_cast(anchor_base + message_dialog_ctor_rva); g_add_screen = reinterpret_cast(anchor_base + add_screen_rva); g_numbox_factory = reinterpret_cast(anchor_base + numbox_factory_rva); + g_slider_vtable = anchor_base + slider_vtable_rva; g_feature_enabled = true; @@ -2824,6 +3113,13 @@ namespace big::mod_settings static auto snv_hook = hooking::detour_hook_helper::add_queue( "sgg::GUIComponentNumBox::SetNumberValue", set_number_value); + + // Optional: persists user drags on our slider rows (filtered to our rows via find_row, so it is a + // no-op for the native audio sliders). If absent, bounded numbers render as the number-box stepper. + if (slider_set_fraction) + { + static auto set_fraction_hook = hooking::detour_hook_helper::add_queue("sgg::GUIComponentSlider::SetFraction", slider_set_fraction); + } static auto update_hook = hooking::detour_hook_helper::add_queue("sgg::MiscSettingsScreen::Update", update); static auto handle_input_hook = hooking::detour_hook_helper::add_queue( diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index fe50812..5103913 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -37,6 +37,12 @@ namespace big::mod_settings bool hidden = false; // author asked to omit this row entirely bool restart_required = false; // change only takes effect after a game restart bool freetext = false; // force a bounded number to freetext entry (not the stepper) + + // Number-display options (mainly for the slider). is_percentage shows a 0..1 value as 0..100 and + // appends "%"; show_as_percentage only appends "%" (no scaling). Setting show_as_percentage in + // addition to is_percentage is a no-op. The stored config value is never modified by either. + bool show_as_percentage = false; + bool is_percentage = false; }; // True if a mod author declared this setting as requiring a game restart to take effect From 551fd31d65ad33693276e1ce7950bab4f5ac2f00 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:12:17 +0100 Subject: [PATCH 021/100] Add Mods-tab controller support: initial focus, press-A slider/enum entry, B back-nav, smooth slider adjust --- src/hades2/mod_settings/mod_settings.cpp | 182 +++++++++++++++++++---- src/hades2/mod_settings/sgg_gui.hpp | 14 +- 2 files changed, 159 insertions(+), 37 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 511643d..2e0f1b3 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -124,6 +124,18 @@ namespace big::mod_settings // missing (it is sometimes emitted inline). Resolved off the same button-ctor anchor. static constexpr std::uintptr_t push_back_rva = 0x14'1E'D0; + // sgg::MenuScreen::TeleportCursorTo(this, GUIComponent*) - the 2-arg overload that drops the + // controller/keyboard free-form cursor onto a component - and sgg::ConfigOptions::UseMouse, the + // global bool that is false in controller/keyboard mode. Both addressed by RVA off the anchor. + static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; + static constexpr std::uintptr_t config_use_mouse_rva = 0x83'69'15; + + // &sgg::Controls::Cancel (the remappable Back/Cancel action that folds together controller B and + // keyboard Esc) and &sgg::Controls::Select (controller A + Enter); their first int is the id + // indexing InputHandler's control-state array. + static constexpr std::uintptr_t config_cancel_rva = 0x55'12'20; + static constexpr std::uintptr_t config_select_rva = 0x55'1D'80; + // sgg::GUIComponentNumBox field offsets (DIA-validated on the current Ship build). sizeof 0x5D0; // derives directly from GUIComponent (not GUIComponentButton). static constexpr std::size_t numbox_value_offset = 0x5'40; // mNumberValue (float) @@ -185,6 +197,9 @@ namespace big::mod_settings using gui_component_ctor_fn = void (*)(void* self, std::uint64_t location_packed); using slider_defaults_fn = void (*)(void* slider); using slider_set_fraction_fn = void (*)(void* slider, float fraction, bool notify); + using teleport_cursor_fn = void (*)(void* menu_screen, GUIComponent* component); + using component_focused_fn = void (*)(void* misc_settings_screen, GUIComponent* component); + using input_get_state_fn = std::uint32_t (*)(void* input_handler, const void* remappable_control); // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -219,7 +234,13 @@ namespace big::mod_settings static gui_component_ctor_fn g_textbox_ctor = nullptr; static slider_defaults_fn g_slider_defaults = nullptr; static slider_set_fraction_fn g_slider_set_fraction = nullptr; - static std::uintptr_t g_slider_vtable = 0; // runtime slider vftable address (anchor_base + slider_vtable_rva) + static std::uintptr_t g_slider_vtable = 0; // runtime slider vftable address (anchor_base + slider_vtable_rva) + static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) + static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) + static component_focused_fn g_component_focused = nullptr; // focuses a row so it receives stick input + green + static input_get_state_fn g_input_get_state = nullptr; // reads a remappable control's per-frame state + static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) + static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A / Enter) // Set true by register_hooks only once every engine symbol, RVA and offset the Mods tab needs has // resolved for the running game build. While false no hooks are installed and the tab is absent; @@ -2313,6 +2334,63 @@ namespace big::mod_settings } } + // Focuses the first selectable row so the controller/keyboard cursor lands on it, as a native + // category does when shown. The engine's DoShowCategory teleports the free-form cursor onto + // mOptions[0] and clears mCategoryFocused (switching from tab to option navigation) only when the + // option list is already populated at that point; our rows are appended afterwards, so it is + // skipped - leaving the screen in tab-navigation mode, which is why the stick never reaches the + // rows (no highlight, sliders ignore left/right) until the tab is selected a second time. Mouse + // mode is left untouched (the mouse drives hover itself; teleporting would yank the pointer). + static void focus_first_row(MiscSettingsScreen* screen) + { + if (!g_teleport_cursor || (g_use_mouse && *g_use_mouse)) + { + return; + } + for (const auto& row : g_rows) + { + GUIComponent* c = row.component; + if (c && !row.disabled && c->m_is_useable && !c->m_hidden) + { + g_teleport_cursor(screen, c); // drop the cursor on the row; next Update focuses it + screen->m_category_focused = false; // hand navigation from the tab bar to the option rows + return; + } + } + } + + // Queues a one-level back navigation inside a mod's settings: a nested group returns to its parent + // section, and the root returns to the mod list. Applied next Update via apply_nav. + static void request_back_nav() + { + const auto dot = g_view_section.rfind('.'); + if (dot != std::string::npos) + { + g_pending_view = View::mod_settings; + g_pending_stem = g_view_stem; + g_pending_section = g_view_section.substr(0, dot); + } + else + { + g_pending_view = View::mod_list; + g_pending_stem.clear(); + g_pending_section.clear(); + } + g_nav_pending = true; + } + + // True if a remappable control (e.g. Back/Cancel = controller B + keyboard Esc, or Select = + // controller A + Enter) was pressed this frame. Bit 0x4 of the control's state is "was pressed" + // (edge, not held). + static bool control_pressed(void* input, const void* control) + { + if (!input || !g_input_get_state || !control) + { + return false; + } + return (g_input_get_state(input, control) & 0x4u) != 0; + } + static void build_panel(MiscSettingsScreen* screen, bool instant = false) { // A rebuild frees and recreates the row components, so the cached highlighted-row pointer @@ -2382,6 +2460,14 @@ namespace big::mod_settings // Value displays are not laid out by the scroll pass; place them on their key rows now. sync_value_columns(); + + // On a real view change (tab entry, drilling into a mod, going back), drop the cursor on the + // first row so it highlights immediately like a native category. Skipped on in-place refreshes + // so committing an edit or toggling "enabled" does not yank focus back to the top. + if (!instant) + { + focus_first_row(screen); + } } // Applies a queued navigation (mod list <-> a mod's settings) by rebuilding the panel. @@ -2682,10 +2768,16 @@ namespace big::mod_settings // Value-change hook for our native slider rows. GUIComponentSlider::SetFraction is called with // notify=true on every user drag / left-right adjust (the native handler also rewrites the value - // text to a percentage). We run the original, then, for our rows, snap the post-clamp fraction to - // the setting's step, persist the mapped [min,max] value on change, and restore the real value text. - // notify is false only for our own initial paint (make_slider_row), so filtering on it skips that. - // Fires for the native audio sliders too, hence the find_row filter. + // text to a percentage). We run the original, then, for our rows, map the post-clamp fraction to the + // [min,max] value, snap that to the setting's step for storage/display, and restore the real value + // text. notify is false only for our own initial paint (make_slider_row), so filtering on it skips + // that. Fires for the native audio sliders too, hence the find_row filter. + // + // We deliberately leave mFraction continuous (we do NOT write the snapped value back to it): the + // native adjust accumulates a small per-frame delta into mFraction, so re-snapping it each frame + // would discard any delta smaller than half a step and a partial stick deflection would never move + // the slider. The fill therefore tracks the stick smoothly (as the vanilla sliders do) while the + // stored value and the value text snap to step. static void hook_GUIComponentSlider_SetFraction(void* self, float fraction, bool notify) { big::g_hooking->get_original()(self, fraction, notify); @@ -2706,7 +2798,7 @@ namespace big::mod_settings const double step_v = row->stepper_step; const double range = max_v - min_v; - // Post-clamp fraction the original just wrote, mapped back to the value, snapped to step. + // Continuous post-clamp fraction the original just wrote, mapped to the value and snapped to step. const float f = *reinterpret_cast(reinterpret_cast(self) + slider_fraction_offset); double v = min_v + static_cast(f) * range; if (step_v > 0.0 && range > 0.0) @@ -2722,10 +2814,6 @@ namespace big::mod_settings v = max_v; } - // Rest the bar on the snapped position by writing mFraction straight back. Calling SetFraction - // again would re-enter this hook, so we set the field directly (the fill redraws from it). - *reinterpret_cast(reinterpret_cast(self) + slider_fraction_offset) = (range > 0.0) ? static_cast((v - min_v) / range) : 0.0f; - if (row->entry->get_value_base() != v) { capture_restart_baseline(row->entry); @@ -2885,13 +2973,21 @@ namespace big::mod_settings return result; } - // While a freetext setting is being edited, read Enter (confirm) and Escape (cancel) - // from the game's own per-frame input, commit/cancel here, then swallow the screen's - // input handling entirely so menu navigation and the Escape-to-close do not react. - // Committing here (rather than in Update) is important: HandleInput returns true this - // frame, so a submitting mouse click is swallowed and cannot also activate the row it - // lands on. Returning true without calling the original bypasses the whole close chain - // (the base MenuScreen::HandleInput is only reached via this function's tail-call). + // While a freetext setting is being edited, read Enter (confirm) and Escape (cancel) from the game's + // own per-frame input, commit/cancel here, then swallow the screen's input handling entirely so menu + // navigation and the Escape-to-close do not react. Committing here (rather than in Update) is + // important: HandleInput returns true this frame, so a submitting mouse click is swallowed and cannot + // also activate the row it lands on. Returning true without calling the original bypasses the whole + // close chain (the base MenuScreen::HandleInput is only reached via this function's tail-call). + // + // Not editing, controller/keyboard, nothing entered yet: we drive two per-option behaviours the + // native focus delegates would (which our injected rows lack). Select (A / Enter) enters a slider or + // enum row so the stick then adjusts it; the native code exits it on the next A/B. And inside a mod's + // settings, Back/Cancel (controller B / keyboard Esc) steps back one level - in option-navigation + // mode the native Cancel handler returns the cursor to the tab bar instead of reaching our ExitScreen + // back-nav, so we detect it here (before the original) and run the back-nav ourselves. Both swallow + // the press. When a widget is already entered we do nothing: native routes the stick to it and exits + // on A/B. static bool hook_MiscSettingsScreen_HandleInput(void* self, void* input, float x) { if (g_editing) @@ -2911,6 +3007,32 @@ namespace big::mod_settings return true; } + auto* screen = static_cast(self); + const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); + if (on_mods_tab && !(g_use_mouse && *g_use_mouse) && !screen->m_component_focused) + { + auto* menu = reinterpret_cast(screen); + + // Select enters a slider / enum row (so the stick adjusts it); toggles and buttons are left + // to the native component pass. + if (g_component_focused && control_pressed(input, g_controls_select)) + { + PanelRow* row = find_row(menu->m_mouse_over_component); + if (row && !row->disabled && (row->is_slider || row->is_enum)) + { + g_component_focused(screen, menu->m_mouse_over_component); + return true; // consume the enter press + } + } + + // Back/Cancel steps back one level instead of the native return-to-tab-bar / close. + if (g_view == View::mod_settings && !g_nav_pending && control_pressed(input, g_controls_cancel)) + { + request_back_nav(); + return true; + } + } + return big::g_hooking->get_original()(self, input, x); } @@ -2930,20 +3052,7 @@ namespace big::mod_settings const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); if (on_mods_tab && g_view == View::mod_settings) { - const auto dot = g_view_section.rfind('.'); - if (dot != std::string::npos) - { - g_pending_view = View::mod_settings; - g_pending_stem = g_view_stem; - g_pending_section = g_view_section.substr(0, dot); - } - else - { - g_pending_view = View::mod_list; - g_pending_stem.clear(); - g_pending_section.clear(); - } - g_nav_pending = true; + request_back_nav(); return; // veto the close; apply_nav applies the new view next Update } @@ -3049,6 +3158,13 @@ namespace big::mod_settings const auto slider_set_fraction = big::hades2_symbol_to_address["sgg::GUIComponentSlider::SetFraction"]; g_slider_set_fraction = slider_set_fraction.as_func(); + // Controller focus: ComponentFocused makes a row the focused option (so the stick reaches it), + // GetState reads the Back/Cancel control edge for our drilldown back-nav. Both by name; the + // Controls::Cancel address is RVA-relative (resolved below). Optional - their absence only + // degrades controller support, not the tab. + g_component_focused = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ComponentFocused"].as_func(); + g_input_get_state = big::hades2_symbol_to_address["sgg::InputHandler::GetState"].as_func(); + // The num-box factory (a template instantiation) and the restart-dialog ctor / AddScreen // overloads cannot be picked by name from the PDB, so they are addressed by hardcoded RVA off // the button-ctor anchor. Those RVAs - and every struct offset this feature uses - are valid @@ -3094,6 +3210,10 @@ namespace big::mod_settings g_add_screen = reinterpret_cast(anchor_base + add_screen_rva); g_numbox_factory = reinterpret_cast(anchor_base + numbox_factory_rva); g_slider_vtable = anchor_base + slider_vtable_rva; + g_teleport_cursor = reinterpret_cast(anchor_base + teleport_cursor_rva); + g_use_mouse = reinterpret_cast(anchor_base + config_use_mouse_rva); + g_controls_cancel = reinterpret_cast(anchor_base + config_cancel_rva); + g_controls_select = reinterpret_cast(anchor_base + config_select_rva); g_feature_enabled = true; diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp index 0f8abd7..64f36a4 100644 --- a/src/hades2/mod_settings/sgg_gui.hpp +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -132,12 +132,13 @@ namespace big::mod_settings::sgg GUIComponentButton* m_editor_options_button; // +0x3C8 char m_pad_c[0x28]; GUIComponentButton* m_debug_options_button; // +0x3F8 - char m_pad_d[0x08]; - eastl_vector m_options; // +0x408 - char m_pad_e[0x20]; // 0x420 .. 0x440 - GUIComponent* m_defaults_button; // +0x440 (bottom "Reset" prompt) - char m_pad_f[0x18]; // 0x448 .. 0x460 - GUIComponent* m_description_box; // +0x460 + bool m_category_focused; // +0x400 (false = option navigation, true = tab navigation) + char m_pad_d[0x07]; // 0x401 .. 0x408 + eastl_vector m_options; // +0x408 + char m_pad_e[0x20]; // 0x420 .. 0x440 + GUIComponent* m_defaults_button; // +0x440 (bottom "Reset" prompt) + char m_pad_f[0x18]; // 0x448 .. 0x460 + GUIComponent* m_description_box; // +0x460 }; static_assert(offsetof(MiscSettingsScreen, m_page_start_index) == 0x3'44); @@ -149,6 +150,7 @@ namespace big::mod_settings::sgg static_assert(offsetof(MiscSettingsScreen, m_credits_options_button) == 0x3'C0); static_assert(offsetof(MiscSettingsScreen, m_editor_options_button) == 0x3'C8); static_assert(offsetof(MiscSettingsScreen, m_debug_options_button) == 0x3'F8); + static_assert(offsetof(MiscSettingsScreen, m_category_focused) == 0x4'00); static_assert(offsetof(MiscSettingsScreen, m_options) == 0x4'08); static_assert(offsetof(MiscSettingsScreen, m_defaults_button) == 0x4'40); static_assert(offsetof(MiscSettingsScreen, m_description_box) == 0x4'60); From 29f5f3543cb78e27aa51af2417194f42b717a4ed Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:03:40 +0100 Subject: [PATCH 022/100] Improve Mods-tab restart prompt: display_name, trigger on leaving a mod, and locale-aware message text --- src/hades2/mod_settings/mod_settings.cpp | 121 ++++++++++++++++------- 1 file changed, 84 insertions(+), 37 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 2e0f1b3..4a12254 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -129,6 +129,9 @@ namespace big::mod_settings // global bool that is false in controller/keyboard mode. Both addressed by RVA off the anchor. static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; static constexpr std::uintptr_t config_use_mouse_rva = 0x83'69'15; + // sgg::ConfigOptions::Language: eastl string holding the current display-language code (e.g. "en", + // "zh-TW"). Used to pick text/blank characters the current locale's font can render. + static constexpr std::uintptr_t config_language_rva = 0x83'69'20; // &sgg::Controls::Cancel (the remappable Back/Cancel action that folds together controller B and // keyboard Esc) and &sgg::Controls::Select (controller A + Enter); their first int is the id @@ -237,6 +240,7 @@ namespace big::mod_settings static std::uintptr_t g_slider_vtable = 0; // runtime slider vftable address (anchor_base + slider_vtable_rva) static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) + static const char* g_config_language = nullptr; // sgg::ConfigOptions::Language (eastl SSO string, code chars at offset 0) static component_focused_fn g_component_focused = nullptr; // focuses a row so it receives stick input + green static input_get_state_fn g_input_get_state = nullptr; // reads a remappable control's per-frame state static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) @@ -1638,27 +1642,6 @@ namespace big::mod_settings g_edit_cancel = false; } - // Replaces each ASCII space with a non-breaking space (U+00A0, UTF-8 0xC2 0xA0). The message - // textbox auto-wraps at breakable spaces (computed at the template font size, before our font - // scaling), which would split a single logical line; non-breaking spaces keep it on one line. - static std::string to_non_breaking(const std::string& text) - { - std::string out; - out.reserve(text.size() + text.size() / 4); - for (char c : text) - { - if (c == ' ') - { - out += "\xC2\xA0"; - } - else - { - out += c; - } - } - return out; - } - // Composite key ("\0
\0") uniquely identifying a config entry across mods. static std::string restart_change_key(toml_v2::config_file::config_entry_base* entry, const std::string& stem) { @@ -1684,6 +1667,18 @@ namespace big::mod_settings g_restart_baselines.try_emplace(key, entry->get_serialized_value()); } + // The friendly display name for a setting: the author's `display_name` override when provided, + // otherwise the prettified key. Mirrors how the setting rows are labelled. + static std::string setting_display_name(const std::string& stem, const std::string& section, const std::string& key) + { + const auto meta = get_setting_metadata(stem, section, key); + if (meta && !meta->name.empty()) + { + return meta->name; + } + return key_to_display(key); + } + // Records or clears a restart-required setting change after the value has been written. If the // new value equals the session baseline (e.g. a toggle flipped and flipped back, or a number // re-typed to its original), nothing actually changed, so the setting is dropped from the @@ -1710,9 +1705,10 @@ namespace big::mod_settings } else { - // Keep each mod/setting/value entry on one line (see to_non_breaking). - const std::string line = display_name_from_stem(stem) + ": " + key_to_display(entry->m_definition.m_key) + " (" + new_value_display + ")"; - g_restart_changes[key] = to_non_breaking(line); + // Stored plain; word-wrapped with regular spaces for the dialog in build_restart_message. + const std::string line = display_name_from_stem(stem) + ": " + + setting_display_name(stem, entry->m_definition.m_section, entry->m_definition.m_key) + " (" + new_value_display + ")"; + g_restart_changes[key] = line; } g_restart_required = !g_restart_changes.empty(); @@ -2544,26 +2540,57 @@ namespace big::mod_settings } } - // Builds the restart-popup body text from the changes collected this session. Blank lines are a - // single non-breaking space (U+00A0): ShowText trims ASCII-whitespace-only lines (so "\n\n" and - // "\n \n" collapse) but keeps an nbsp line. A sacrificial trailing nbsp line is appended because - // the formatter also trims the LAST whitespace-only line, which would otherwise merge the blank - // before the outro into it. Intro/outro and each change entry are non-breaking so the - // width-greedy formatter keeps each on one line. + // True when the game's current display language uses a CJK font (zh-CN, zh-TW, ja, ko). Those fonts + // have no glyph for the non-breaking space U+00A0 and draw a visible '*' instead, so the restart + // message uses regular spaces and a U+3000 blank for them. Every other language uses a + // Latin/Cyrillic/Greek font that renders U+00A0 invisibly - which is needed there to keep the + // (English) mod/setting entries from wrapping mid-line. + static bool current_language_is_cjk() + { + if (!g_config_language) + { + return false; + } + const char* code = g_config_language; // eastl SSO string: null-terminated code chars at offset 0 + return std::strncmp(code, "zh", 2) == 0 || std::strncmp(code, "ja", 2) == 0 || std::strncmp(code, "ko", 2) == 0; + } + + // Builds the restart-popup body text from the changes collected this session. The character choices + // depend on the current locale's font (see current_language_is_cjk): CJK locales use regular spaces + // and a U+3000 ideographic-space blank line; all others use non-breaking spaces (U+00A0), which keep + // each intro/entry/outro line whole under the width-greedy formatter and double as the blank line. + // Both blank characters survive ShowText's ASCII-whitespace-line trim; a sacrificial trailing blank + // is appended because the formatter also trims the last whitespace-only line. static std::string build_restart_message() { - const std::string blank = "\xC2\xA0"; // nbsp: a whitespace line ShowText will not trim + const bool cjk = current_language_is_cjk(); + const std::string blank = cjk ? "\xE3\x80\x80" : "\xC2\xA0"; // U+3000 (CJK) or U+00A0 (other) - std::string msg = to_non_breaking("A restart is required because you changed these settings:"); + const auto spaced = [cjk](const std::string& s) -> std::string + { + if (cjk) + { + return s; // regular spaces render in the CJK font; the entries fit without non-breaking + } + std::string out; + out.reserve(s.size() + s.size() / 4); + for (char c : s) + { + out += (c == ' ') ? std::string("\xC2\xA0") : std::string(1, c); + } + return out; + }; + + std::string msg = spaced("A restart is required because you changed these settings:"); msg += "\n" + blank + "\n"; for (const auto& change : g_restart_changes) { - msg += change.second; + msg += spaced(change.second); msg += "\n"; } msg += blank + "\n"; - msg += to_non_breaking("The game will now close. Please restart it to apply the changes."); - msg += "\n" + blank; // sacrificial trailing blank so the one above the outro survives + msg += spaced("The game will now close. Please restart it to apply the changes."); + msg += "\n" + blank; return msg; } @@ -2949,7 +2976,23 @@ namespace big::mod_settings // Only act while this screen is actually showing the Mods tab. if (on_mods_tab) { - apply_nav(screen); + // Stepping from a mod's settings back to the mod overview is the "done configuring this + // mod" point: if a restart-required setting changed this session, show the restart prompt + // now (it forces the restart) and stay on the current view under it, rather than + // returning to the overview. Only the final step out of the mod (to the list) triggers + // it; stepping between nested groups stays within mod_settings. + const bool leaving_mod = (g_view == View::mod_settings) && (g_pending_view == View::mod_list); + bool prompted = false; + if (leaving_mod && g_restart_required && !g_restart_prompt_shown) + { + g_restart_prompt_shown = true; + void* screen_manager = *reinterpret_cast(reinterpret_cast(screen) + screen_manager_offset); + prompted = show_restart_dialog(screen_manager, build_restart_message()); + } + if (!prompted) + { + apply_nav(screen); + } } g_nav_pending = false; } @@ -3025,7 +3068,10 @@ namespace big::mod_settings } } - // Back/Cancel steps back one level instead of the native return-to-tab-bar / close. + // Back/Cancel inside a mod's settings steps back one level (nested group -> parent section, + // root -> mod list) instead of the native return-to-tab-bar. In the mod list it is left to + // the native handler. The restart prompt is shown when stepping from a mod's settings back + // to the overview (see apply_nav). if (g_view == View::mod_settings && !g_nav_pending && control_pressed(input, g_controls_cancel)) { request_back_nav(); @@ -3212,6 +3258,7 @@ namespace big::mod_settings g_slider_vtable = anchor_base + slider_vtable_rva; g_teleport_cursor = reinterpret_cast(anchor_base + teleport_cursor_rva); g_use_mouse = reinterpret_cast(anchor_base + config_use_mouse_rva); + g_config_language = reinterpret_cast(anchor_base + config_language_rva); g_controls_cancel = reinterpret_cast(anchor_base + config_cancel_rva); g_controls_select = reinterpret_cast(anchor_base + config_select_rva); From 2adeb9ab3771719f57e6d2c3a09a37f08e5517eb Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:46:34 +0100 Subject: [PATCH 023/100] Capitalize the first letter in the Mods-tab friendly-name reformat --- src/hades2/mod_settings/mod_settings.cpp | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 4a12254..3ed4520 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1390,8 +1390,9 @@ namespace big::mod_settings // Turns an identifier into a friendly display string: underscores become spaces, and camelCase / // PascalCase word boundaries are split ("z_ThisConfigKey" -> "z This Config Key"). An acronym run // splits before its final capital when that capital starts a lowercase word ("HTTPServer" -> - // "HTTP Server"). Used for both setting keys and mod names (via display_name_from_stem). Authors - // can override this entirely with `display_name`. + // "HTTP Server"). The first letter is capitalized ("enabled" -> "Enabled"). Used for both setting + // keys and mod names (via display_name_from_stem). Authors can override this entirely with + // `display_name`. static std::string key_to_display(const std::string& key) { const auto is_upper = [](char c) @@ -1425,6 +1426,20 @@ namespace big::mod_settings } out.push_back(c); } + + // Capitalize the first letter so a key/mod name with no author display_name still reads as a + // proper title ("enabled" -> "Enabled"). + for (char& c : out) + { + if (c != ' ') + { + if (is_lower(c)) + { + c = static_cast(c - ('a' - 'A')); + } + break; + } + } return out; } From 933d7d801a5bb9f8da179d20684b7e7f448ec21f Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:11:32 +0100 Subject: [PATCH 024/100] Fix Mods tab: proper localization id, restart-prompt display_name + leaving-mod trigger, locale-aware text, capitalized names --- src/hades2/mod_settings/mod_settings.cpp | 16 ++++++++++++++++ src/hades2/mod_settings/sgg_gui.hpp | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 3ed4520..df28bcd 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -633,6 +633,22 @@ namespace big::mod_settings button->m_hidden = false; button->m_is_useable = true; + // Point the button's localization id at "Mods" so the engine's own label pipeline resolves it. + // The reused button ships with DisplayNameId "MiscSettingsScreen_EditorOptions" (-> "Editor"); + // interning "Mods" and writing its id into mDisplayNameId makes GUIComponentButton::UseDefaultText + // re-derive "Mods" natively - including after a language change, which re-runs that derivation and + // would otherwise revert the tab to "Editor". "Mods" has no text-data entry, so the lookup misses + // and the engine renders the raw key ("Mods") verbatim in every language. + if (g_hash_lookup) + { + HashGuid id{}; + g_hash_lookup(&id, "Mods", 4); + *reinterpret_cast(reinterpret_cast(button) + sgg::gui_component_button_display_name_id_offset) = id.m_id; + } + + // Apply the label now for the initial display: the original constructor already rendered the + // native "Editor" text from the old id, and UseDefaultText only re-derives on the next + // localization pass. Subsequent language changes are handled by the id above, not here. if (g_set_label) { g_set_label(button, "Mods"); diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp index 64f36a4..a176cac 100644 --- a/src/hades2/mod_settings/sgg_gui.hpp +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -89,6 +89,14 @@ namespace big::mod_settings::sgg inline constexpr std::size_t gui_component_button_owner_offset = 0x5'A0; inline constexpr std::size_t gui_component_button_size = 0x5'B0; + // Byte offset of GUIComponentButton::mDisplayNameId (sgg::HashGuid: a 32-bit interned-string id). + // The engine derives a button's visible label from this id: GUIComponentButton::UseDefaultText + // resolves the id back to its interned string, looks that up in the localized text data, and sets + // the label from the result (falling back to the raw string on a miss). UseDefaultText re-runs on + // every localization pass, including a live language change, so this id - not any string handed to + // SetDisplayName - is what determines the persistent label. + inline constexpr std::size_t gui_component_button_display_name_id_offset = 0x1'68; + // sgg::MenuScreen, the base of MiscSettingsScreen. mComponents owns every live widget // that is drawn and hit-tested; freed components are dropped from it. mAnchor is the // base location the engine gives freshly created option components. From 0e5ee8e9d7a006563a55a3dedfe3f79c3eb9ad5d Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:14:07 +0100 Subject: [PATCH 025/100] Persist native settings before forced restart --- src/hades2/mod_settings/mod_settings.cpp | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index df28bcd..12409d7 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -212,6 +212,12 @@ namespace big::mod_settings using hash_lookup_fn = HashGuid* (*)(HashGuid * out, const char* str, std::size_t len); + // sgg::ProfileManager::SaveProfile(eastl::string* profileName, bool showSpinner, bool async): + // serializes the active profile (language, audio volumes, resolution/window/VSync/graphics, and all + // gameplay/interface/accessibility toggles) to disk. Called synchronous (async=false) to guarantee + // the write completes before we force a restart. + using save_profile_fn = char (*)(void* profile_name, bool show_spinner, bool async); + static ctor_fn g_button_ctor = nullptr; static push_back_fn g_push_back = nullptr; static apply_data_fn g_apply_data = nullptr; @@ -245,6 +251,8 @@ namespace big::mod_settings static input_get_state_fn g_input_get_state = nullptr; // reads a remappable control's per-frame state static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A / Enter) + static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) + static void* g_active_profile = nullptr; // &sgg::ProfileManager::ACTIVE_PROFILE (eastl::string, the profile name arg) // Set true by register_hooks only once every engine symbol, RVA and offset the Mods tab needs has // resolved for the running game build. While false no hooks are installed and the tab is absent; @@ -2640,6 +2648,22 @@ namespace big::mod_settings buf[23] = static_cast(23 - n); } + // Persists the game's native Options settings (language, audio volumes, resolution/window/graphics, + // and all gameplay/interface/accessibility toggles) to disk. The engine normally does this only + // when the options screen finishes closing (MiscSettingsScreen::OnExit -> ProfileManager::SaveProfile), + // which never runs when we force a restart. So any native settings the player changed earlier in the + // same options session would be lost. Call this immediately before terminating the process, using + // SaveProfile's synchronous path (async=false, no save spinner) so the files are written before we + // exit. Keybinds are excluded on purpose: they are saved separately when the Controls sub-screen + // closes, so they are already on disk by the time the player is back on the main options screen. + static void flush_native_settings() + { + if (g_save_profile && g_active_profile) + { + g_save_profile(g_active_profile, false, false); + } + } + // Shows the native single-button "restart required" message box (sgg::MessageDialog, the same // box the game uses in the main menu for save/file errors). `message` is shown as the body // text. Its only button closes the game (handled in the OnClicked hook) - a restart-required @@ -2702,6 +2726,7 @@ namespace big::mod_settings } MessageBoxW(nullptr, L"A changed mod setting requires a restart. The game will now close - please restart it.", L"Hell2Modding - Restart Required", MB_OK | MB_ICONWARNING | MB_SETFOREGROUND); + flush_native_settings(); TerminateProcess(GetCurrentProcess(), 0); return false; } @@ -2897,6 +2922,7 @@ namespace big::mod_settings if (self && self == g_restart_confirm_button) { big::g_hooking->get_original()(self, location); + flush_native_settings(); TerminateProcess(GetCurrentProcess(), 0); } @@ -3242,6 +3268,14 @@ namespace big::mod_settings g_component_focused = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ComponentFocused"].as_func(); g_input_get_state = big::hades2_symbol_to_address["sgg::InputHandler::GetState"].as_func(); + // Native-settings flush before a forced restart: SaveProfile serializes the active profile + // (language, volumes, graphics, gameplay/interface toggles) to disk; ACTIVE_PROFILE is the + // profile-name string it takes. Both are named PDB globals/functions. Optional - if either is + // missing we simply skip the flush (the forced restart still happens), so native changes made + // this session would be lost, but nothing crashes. + g_save_profile = big::hades2_symbol_to_address["sgg::ProfileManager::SaveProfile"].as_func(); + g_active_profile = big::hades2_symbol_to_address["sgg::ProfileManager::ACTIVE_PROFILE"].as(); + // The num-box factory (a template instantiation) and the restart-dialog ctor / AddScreen // overloads cannot be picked by name from the PDB, so they are addressed by hardcoded RVA off // the button-ctor anchor. Those RVAs - and every struct offset this feature uses - are valid From c09cdaa722830509f4afcb47fe77f2dbd58e7e27 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:14:30 +0100 Subject: [PATCH 026/100] Disable OS fallback restart confirmation window --- src/hades2/mod_settings/mod_settings.cpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 12409d7..c7f348c 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2668,7 +2668,11 @@ namespace big::mod_settings // box the game uses in the main menu for save/file errors). `message` is shown as the body // text. Its only button closes the game (handled in the OnClicked hook) - a restart-required // change must not be cancellable, since cancelling would have to undo the change. Returns true - // if the native dialog was shown; otherwise falls back to a MessageBox (OK closes the game). + // if the native dialog was shown. Returns false only if it could not be built (no screen manager + // or allocation failure); the caller then proceeds normally without forcing a restart - the + // restart-required change is already saved to the mod's config and applies on the next manual + // restart. The dialog machinery is derived off the verified build anchor, so a mismatched game + // build disables the whole tab up front rather than reaching here. static bool show_restart_dialog(void* screen_manager, const std::string& message) { if (screen_manager && g_message_dialog_ctor && g_add_screen) @@ -2725,9 +2729,6 @@ namespace big::mod_settings } } - MessageBoxW(nullptr, L"A changed mod setting requires a restart. The game will now close - please restart it.", L"Hell2Modding - Restart Required", MB_OK | MB_ICONWARNING | MB_SETFOREGROUND); - flush_native_settings(); - TerminateProcess(GetCurrentProcess(), 0); return false; } From ee17a294b2b0821add7757cbb9cfc82981cd55bd Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:38:42 +0100 Subject: [PATCH 027/100] Add rom.mod_settings.opt_out(): grey out opted-out mods in the menu with an explanatory note --- src/hades2/mod_settings/config_api.cpp | 41 +++++++++++ src/hades2/mod_settings/mod_settings.cpp | 87 ++++++++++++++++-------- src/hades2/mod_settings/mod_settings.hpp | 6 ++ 3 files changed, 104 insertions(+), 30 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 6bae33d..0b49e24 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,11 @@ namespace big::mod_settings // Keyed the same way as g_setting_metadata (guid + '\0' + section + '\0' + key). static std::map g_setting_default; + // Guids of mods that called rom.mod_settings.opt_out(), i.e. asked not to be configured through + // the in-game menu. Guarded by g_metadata_mutex. Cleared and rebuilt on each Lua-state init (see + // bind_config_api) because opt_out re-runs with each mod's main.lua. + static std::set g_opted_out_mods; + static std::string metadata_key(const std::string& guid, const std::string& section, const std::string& key) { std::string k; @@ -111,6 +117,12 @@ namespace big::mod_settings return it->second; } + bool mod_opted_out(const std::string& guid) + { + std::scoped_lock lock(g_metadata_mutex); + return g_opted_out_mods.count(guid) != 0; + } + // Finds the byte offset of a key's definition (" =") in config.lua source, whole-word and // not "==", or npos. The first match is the key's place in the returned `config` defaults table // (defined before configDesc), which is the author's intended display order. Occurrences inside @@ -573,13 +585,42 @@ namespace big::mod_settings return sol::make_object(ts, mod_config_proxy{cf.get(), "config"}); } + // rom.mod_settings.opt_out(): the calling mod asks not to be configured through the in-game mod + // settings menu. The mod is still listed there (removing it would look like a missing/broken mod), + // but its row is greyed, cannot be opened, and shows a note pointing the user back to the mod's own + // description for configuration. Keyed by the calling mod's guid (which matches its config-file + // stem), so it applies however the mod manages its config (Chalk or rom.mod_settings.load). + static void opt_out(sol::this_environment this_env) + { + if (!this_env) + { + return; + } + auto* module = big::lua_module::this_from(this_env); + if (!module) + { + return; + } + std::scoped_lock lock(g_metadata_mutex); + g_opted_out_mods.insert(module->guid()); + } + void bind_config_api(sol::state_view& state, sol::table& lua_ext) { + // A fresh Lua state re-runs every mod's main.lua, so drop the previous state's opt-out set + // before those calls re-register it. (Per-mod metadata is cleared in load; opt_out is a + // standalone call with nothing else to hang the clear off, so it is reset here instead.) + { + std::scoped_lock lock(g_metadata_mutex); + g_opted_out_mods.clear(); + } + // Register the live-config proxy usertype once per state (mods never construct it; instances // are returned from load). Its index/new_index read/write the underlying config entries. lua_ext.new_usertype("mod_config_proxy", sol::no_constructor, sol::meta_function::index, &mod_config_proxy::index, sol::meta_function::new_index, &mod_config_proxy::new_index); sol::table ns = lua_ext.create_named("mod_settings"); ns.set_function("load", &load); + ns.set_function("opt_out", &opt_out); } } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index c7f348c..23a04b9 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -442,6 +442,15 @@ namespace big::mod_settings return {}; } + // Shown in the description box in place of the mod description when a mod opted out of the in-game + // settings menu (rom.mod_settings.opt_out()), explaining why its row is greyed and where to + // configure it instead. + static std::string opt_out_note() + { + return "This mod opted out of the in-game settings menu. See the mod's own description for how " + "to configure it, if applicable."; + } + // Escapes the characters the game's text parser (GUIComponentTextBox::Parse) treats as markup, // so arbitrary user text - config values (e.g. Windows paths with '\'), display names and // descriptions - renders verbatim instead of being mangled. The parser reads '\' as an escape @@ -763,9 +772,11 @@ namespace big::mod_settings // A plain left-justified text row (mod names, Back, and non-toggle settings). Applies a // template for a valid font/colours, then retunes the row's own def into the key-rebind // "ControlButton" style - no background graphic, left text, and a text-area hit region - // that hugs the label - and clears any leftover textures. Disabled rows are greyed and - // made non-interactable. - static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false) + // that hugs the label - and clears any leftover textures. Disabled rows are greyed; by + // default they are also hard-disabled (non-selectable). Pass block_input=false to grey a row + // while keeping it selectable, so it can still be highlighted to show its description (used for + // opted-out mods, whose row is greyed and shows a note but must not be drilled into). + static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) { auto* row = create_button(screen); if (!row) @@ -824,7 +835,7 @@ namespace big::mod_settings g_set_label(row, label); } - if (disabled && g_disable) + if (disabled && block_input && g_disable) { g_disable(row); } @@ -1402,10 +1413,17 @@ namespace big::mod_settings for (const auto& [display, stem] : mods) { - if (auto* row = make_text_row(screen, escape_markup(display).c_str())) + // A mod that called rom.mod_settings.opt_out() is still listed (dropping it would look like + // a missing mod), but its row is greyed and cannot be opened, and its description is a note + // pointing back to the mod's own description. The row is greyed without hard-disabling it so + // it stays selectable and the note still shows on hover/focus; the drilldown is blocked by + // the disabled flag in the click handler. + const bool opted_out = mod_opted_out(stem); + if (auto* row = make_text_row(screen, escape_markup(display).c_str(), opted_out, /*block_input*/ false)) { PanelRow pr{row, RowKind::mod_entry, stem, {}}; - pr.description = mod_description_from_stem(stem); + pr.disabled = opted_out; + pr.description = opted_out ? opt_out_note() : mod_description_from_stem(stem); g_rows.push_back(std::move(pr)); } } @@ -2309,33 +2327,42 @@ namespace big::mod_settings } else if (PanelRow* row = find_row(active_row_component(screen))) { - switch (row->kind) + if (row->disabled) { - case RowKind::mod_entry: confirm = "{SL} SELECT"; break; - case RowKind::group: confirm = "{SL} SELECT"; break; - case RowKind::setting: - if (row->entry && row->entry->type() == typeid(bool)) - { - confirm = "{SL} TOGGLE"; - } - else if (row->is_enum) - { - confirm = "{SL} SET"; - } - else if (row->is_slider) - { - confirm = "{SL} SET"; // matches the base-game volume sliders' prompt - } - else if (row->is_stepper) - { - confirm = "{SL} SELECT"; - } - else + // A greyed, non-interactable row (e.g. an opted-out mod) has no confirm action, so + // show no confirm prompt for it. + confirm.clear(); + } + else + { + switch (row->kind) { - confirm = "{SL} EDIT"; // freetext value + case RowKind::mod_entry: confirm = "{SL} SELECT"; break; + case RowKind::group: confirm = "{SL} SELECT"; break; + case RowKind::setting: + if (row->entry && row->entry->type() == typeid(bool)) + { + confirm = "{SL} TOGGLE"; + } + else if (row->is_enum) + { + confirm = "{SL} SET"; + } + else if (row->is_slider) + { + confirm = "{SL} SET"; // matches the base-game volume sliders' prompt + } + else if (row->is_stepper) + { + confirm = "{SL} SELECT"; + } + else + { + confirm = "{SL} EDIT"; // freetext value + } + break; + case RowKind::action: confirm = "{SL} SELECT"; break; } - break; - case RowKind::action: confirm = "{SL} SELECT"; break; } } diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 5103913..8b750ce 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -63,4 +63,10 @@ namespace big::mod_settings // via rom.mod_settings.load, or std::nullopt for keys with no captured default. Used by the // settings menu's Reset action to restore a setting to what config.lua declared. std::optional get_setting_default(const std::string& guid, const std::string& section, const std::string& key); + + // True if a mod called rom.mod_settings.opt_out() from its Lua (keyed by the calling mod's guid, + // which matches a mod config's file stem). The settings menu still lists such a mod, but greys its + // row, blocks drilling into it, and shows an opt-out note in place of its description. Populated + // fresh each Lua-state init (opt_out re-runs with the mod's main.lua). + bool mod_opted_out(const std::string& guid); } // namespace big::mod_settings From f0b5a625c91461205fe550e52c054a6555b0e784 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:51:59 +0100 Subject: [PATCH 028/100] Preserve mod-list scroll position and re-focus the opened mod when backing out of a mod's settings --- src/hades2/mod_settings/mod_settings.cpp | 89 +++++++++++++++++++++--- 1 file changed, 81 insertions(+), 8 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 23a04b9..781e9b1 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -397,6 +397,15 @@ namespace big::mod_settings static std::string g_pending_section; static bool g_nav_reset_to_top = false; // Reset action: force a top (non-instant) rebuild next apply_nav + // Preserve the mod-list scroll position and highlighted mod across a drill-in/back-out. When the + // user opens a mod from a scrolled-down mod list, g_mod_list_start_index records that scroll offset + // and g_mod_list_focus_stem the opened mod; returning to the list restores the offset and re-focuses + // that mod's row (instead of snapping back to the first page). g_restore_mod_list_position gates the + // restore so a fresh tab entry still starts at the top. + static std::uint32_t g_mod_list_start_index = 0; + static std::string g_mod_list_focus_stem; + static bool g_restore_mod_list_position = false; + // Freetext edit state (number/string settings). A click enters edit mode; typed input // is captured in the window procedure and applied on the game thread in the Update hook. static bool g_editing = false; @@ -2403,6 +2412,18 @@ namespace big::mod_settings // skipped - leaving the screen in tab-navigation mode, which is why the stick never reaches the // rows (no highlight, sliders ignore left/right) until the tab is selected a second time. Mouse // mode is left untouched (the mouse drives hover itself; teleporting would yank the pointer). + // Drops the controller/keyboard cursor onto a specific row so the next Update focuses it (green + + // stick input). No-op in mouse mode (the mouse drives hover). The row must be selectable. + static void focus_row(MiscSettingsScreen* screen, GUIComponent* component) + { + if (!g_teleport_cursor || (g_use_mouse && *g_use_mouse) || !component) + { + return; + } + g_teleport_cursor(screen, component); // drop the cursor on the row; next Update focuses it + screen->m_category_focused = false; // hand navigation from the tab bar to the option rows + } + static void focus_first_row(MiscSettingsScreen* screen) { if (!g_teleport_cursor || (g_use_mouse && *g_use_mouse)) @@ -2414,13 +2435,26 @@ namespace big::mod_settings GUIComponent* c = row.component; if (c && !row.disabled && c->m_is_useable && !c->m_hidden) { - g_teleport_cursor(screen, c); // drop the cursor on the row; next Update focuses it - screen->m_category_focused = false; // hand navigation from the tab bar to the option rows + focus_row(screen, c); return; } } } + // The mod-list row for a given mod stem (its mod_entry row), or nullptr if not present. Used to + // re-focus the mod the user just backed out of when returning to a scrolled mod list. + static GUIComponent* mod_row_for_stem(const std::string& stem) + { + for (const auto& row : g_rows) + { + if (row.kind == RowKind::mod_entry && row.stem == stem && row.component) + { + return row.component; + } + } + return nullptr; + } + // Queues a one-level back navigation inside a mod's settings: a nested group returns to its parent // section, and the root returns to the mod list. Applied next Update via apply_nav. static void request_back_nav() @@ -2487,14 +2521,21 @@ namespace big::mod_settings } // Let the engine position, paginate and drive the scrollbar/arrows for the rows. + // + // Returning to the mod list from a mod's settings is a real view change (not instant), which + // would otherwise snap to the top. If the user opened the mod from a scrolled-down list, restore + // that scroll offset so they land back where they were, on the mod they just left. + const bool restoring_list = !instant && g_restore_mod_list_position && g_view == View::mod_list; + std::uint32_t start = 0; - if (instant) + if (instant || restoring_list) { // Clamp the preserved offset in case the row count shrank (e.g. a row became // hidden), keeping a full page in view where possible. const std::uint32_t row_count = static_cast(g_rows.size()); const std::uint32_t max_start = row_count > rows_per_page ? row_count - rows_per_page : 0; - start = prev_start > max_start ? max_start : prev_start; + const std::uint32_t desired = instant ? prev_start : g_mod_list_start_index; + start = desired > max_start ? max_start : desired; } screen->m_page_start_index = start; screen->m_options_per_page = rows_per_page; @@ -2525,10 +2566,25 @@ namespace big::mod_settings // On a real view change (tab entry, drilling into a mod, going back), drop the cursor on the // first row so it highlights immediately like a native category. Skipped on in-place refreshes - // so committing an edit or toggling "enabled" does not yank focus back to the top. + // so committing an edit or toggling "enabled" does not yank focus back to the top. When + // returning to a scrolled mod list, focus the mod the user backed out of (on the restored page) + // rather than the first row. if (!instant) { - focus_first_row(screen); + GUIComponent* restore_focus = restoring_list ? mod_row_for_stem(g_mod_list_focus_stem) : nullptr; + if (restore_focus) + { + focus_row(screen, restore_focus); + } + else + { + focus_first_row(screen); + } + } + + if (restoring_list) + { + g_restore_mod_list_position = false; } } @@ -2545,6 +2601,20 @@ namespace big::mod_settings const bool instant = !g_nav_reset_to_top && (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); g_nav_reset_to_top = false; + // Opening a mod from the mod list: remember where the list was scrolled and which mod was + // opened, so backing out returns to that page with that mod highlighted (g_view is still the + // old view here, so m_page_start_index is the mod list's own scroll offset). + if (g_view == View::mod_list && g_pending_view == View::mod_settings) + { + g_mod_list_start_index = screen->m_page_start_index; + g_mod_list_focus_stem = g_pending_stem; + } + // Returning to the mod list from a mod's settings: restore that saved scroll/focus. + if (g_view == View::mod_settings && g_pending_view == View::mod_list) + { + g_restore_mod_list_position = true; + } + g_view = g_pending_view; g_view_stem = g_pending_stem; g_view_section = g_pending_section; @@ -2769,8 +2839,11 @@ namespace big::mod_settings g_view_stem.clear(); g_view_section.clear(); g_pending_section.clear(); - g_nav_pending = false; - g_nav_reset_to_top = false; + g_nav_pending = false; + g_nav_reset_to_top = false; + g_restore_mod_list_position = false; + g_mod_list_start_index = 0; + g_mod_list_focus_stem.clear(); g_restart_required = false; g_restart_prompt_shown = false; g_restart_confirm_button = nullptr; From dcce1a95392df5093eb91204bbb2d9131c47c1ef Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:20:12 +0100 Subject: [PATCH 029/100] Block disabling a mod that enabled mods depend on, with an info popup listing the dependents --- src/hades2/mod_settings/mod_settings.cpp | 168 +++++++++++++++++++---- 1 file changed, 141 insertions(+), 27 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 781e9b1..f898f81 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2691,13 +2691,15 @@ namespace big::mod_settings return std::strncmp(code, "zh", 2) == 0 || std::strncmp(code, "ja", 2) == 0 || std::strncmp(code, "ko", 2) == 0; } - // Builds the restart-popup body text from the changes collected this session. The character choices - // depend on the current locale's font (see current_language_is_cjk): CJK locales use regular spaces - // and a U+3000 ideographic-space blank line; all others use non-breaking spaces (U+00A0), which keep - // each intro/entry/outro line whole under the width-greedy formatter and double as the blank line. - // Both blank characters survive ShowText's ASCII-whitespace-line trim; a sacrificial trailing blank - // is appended because the formatter also trims the last whitespace-only line. - static std::string build_restart_message() + // Builds a locale-aware popup body: an intro line, a blank line, one line per list entry, a blank + // line, then an outro line (plus a sacrificial trailing blank). The character choices depend on the + // current locale's font (see current_language_is_cjk): CJK locales use regular spaces and a U+3000 + // ideographic-space blank line; all others use non-breaking spaces (U+00A0), which keep each + // intro/entry/outro line whole under the width-greedy formatter and double as the blank line. Both + // blank characters survive ShowText's ASCII-whitespace-line trim; the trailing blank is sacrificial + // because the formatter also trims the last whitespace-only line. Shared by the restart-required and + // dependency-block dialogs. + static std::string build_list_message(const std::string& intro, const std::vector& lines, const std::string& outro) { const bool cjk = current_language_is_cjk(); const std::string blank = cjk ? "\xE3\x80\x80" : "\xC2\xA0"; // U+3000 (CJK) or U+00A0 (other) @@ -2717,19 +2719,31 @@ namespace big::mod_settings return out; }; - std::string msg = spaced("A restart is required because you changed these settings:"); + std::string msg = spaced(intro); msg += "\n" + blank + "\n"; - for (const auto& change : g_restart_changes) + for (const auto& line : lines) { - msg += spaced(change.second); + msg += spaced(line); msg += "\n"; } msg += blank + "\n"; - msg += spaced("The game will now close. Please restart it to apply the changes."); + msg += spaced(outro); msg += "\n" + blank; return msg; } + // Builds the restart-popup body text from the changes collected this session. + static std::string build_restart_message() + { + std::vector lines; + lines.reserve(g_restart_changes.size()); + for (const auto& change : g_restart_changes) + { + lines.push_back(change.second); + } + return build_list_message("A restart is required because you changed these settings:", lines, "The game will now close. Please restart it to apply the changes."); + } + // Builds an empty EASTL SSO string (24-byte layout) in `buf` (>=24 bytes). Passed to the // dialog ctor (message) and AddScreen (name); the real message is applied afterwards via // ShowText. Layout: bytes[0..]=chars, byte[23]=remaining-capacity marker (23 - length). @@ -2761,16 +2775,16 @@ namespace big::mod_settings } } - // Shows the native single-button "restart required" message box (sgg::MessageDialog, the same - // box the game uses in the main menu for save/file errors). `message` is shown as the body - // text. Its only button closes the game (handled in the OnClicked hook) - a restart-required - // change must not be cancellable, since cancelling would have to undo the change. Returns true - // if the native dialog was shown. Returns false only if it could not be built (no screen manager - // or allocation failure); the caller then proceeds normally without forcing a restart - the - // restart-required change is already saved to the mod's config and applies on the next manual - // restart. The dialog machinery is derived off the verified build anchor, so a mismatched game - // build disables the whole tab up front rather than reaching here. - static bool show_restart_dialog(void* screen_manager, const std::string& message) + // Shows the native single-button message box (sgg::MessageDialog, the same box the game uses in the + // main menu for save/file errors), modal over the options screen, with `title` as the heading and + // `message` as the body. When confirm_closes_game is true the confirm button is captured so the + // OnClicked hook closes the game on press (used for a forced restart, which must not be + // cancellable); otherwise the button keeps its native behaviour and simply dismisses the dialog + // (used for informational prompts). Returns true if the dialog was shown. Returns false only if it + // could not be built (no screen manager or allocation failure). The dialog machinery is derived off + // the verified build anchor, so a mismatched game build disables the whole tab up front rather than + // reaching here. + static bool show_message_dialog(void* screen_manager, const char* title, const std::string& message, bool confirm_closes_game) { if (screen_manager && g_message_dialog_ctor && g_add_screen) { @@ -2793,12 +2807,12 @@ namespace big::mod_settings bytes[screen_visible_offset] = 1; bytes[screen_block_input_offset] = 1; - // Set the title + body (raw text; the body carries the restart-causing settings). + // Set the title + body (raw text; the body carries the list of settings/mods). if (g_show_text) { if (auto* title_box = *reinterpret_cast(bytes + dialog_title_offset)) { - g_show_text(title_box, "Restart Required"); + g_show_text(title_box, title); } if (auto* message_box = *reinterpret_cast(bytes + dialog_message_offset)) { @@ -2809,14 +2823,18 @@ namespace big::mod_settings *reinterpret_cast(handle + font_handle_size_ratio_offset) *= restart_message_font_scale; *reinterpret_cast(handle + font_handle_eng_size_ratio_offset) *= restart_message_font_scale; // Escape markup so a path value (e.g. hadesGameFolder) with '\' or brackets in - // the changed-settings list renders verbatim (see escape_markup). + // the listed lines renders verbatim (see escape_markup). const std::string shown = escape_markup(message); g_show_text(message_box, shown.c_str()); } } - // Capture the confirm button so the OnClicked hook closes the game on press. - g_restart_confirm_button = *reinterpret_cast(bytes + dialog_confirm_button_offset); + // Capture the confirm button only when it should close the game; otherwise the native + // confirm behaviour (dismiss the dialog) is left in place. + if (confirm_closes_game) + { + g_restart_confirm_button = *reinterpret_cast(bytes + dialog_confirm_button_offset); + } // Add at the END of the screen list so it draws on top of the options menu. char empty_name[24]; @@ -2829,6 +2847,86 @@ namespace big::mod_settings return false; } + // The "restart required" prompt: its only button closes the game (a restart-required change must + // not be cancellable, since cancelling would have to undo the change). + static bool show_restart_dialog(void* screen_manager, const std::string& message) + { + return show_message_dialog(screen_manager, "Restart Required", message, /*confirm_closes_game*/ true); + } + + // The "can't disable this mod" prompt: purely informational, so its button just dismisses the + // dialog and returns the player to the options screen with the mod left enabled. + static bool show_dependency_dialog(void* screen_manager, const std::string& message) + { + return show_message_dialog(screen_manager, "Cannot Disable Mod", message, /*confirm_closes_game*/ false); + } + + // True if the mod with config-file stem/guid `guid` is currently enabled: the value of its master + // "enabled" root-section toggle, or true when it has no such toggle (a mod with no enable switch is + // always active). Reads the live config value, so it reflects any change made this menu session. + static bool mod_is_enabled(const std::string& guid) + { + for (auto* cfg : toml_v2::config_file::g_config_files) + { + if (!cfg || cfg->m_config_file_stem_as_str != guid) + { + continue; + } + for (auto& [key, entry] : cfg->m_entries) + { + if (entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) + { + return entry->get_value_base(); + } + } + } + return true; + } + + // Display names of the currently-enabled loaded mods that declare `stem` as a dependency (via their + // Thunderstore manifest, which lists dependency guids in dependencies_no_version_number). Disabling + // `stem` while any of these is enabled would break them, so the menu blocks it. A dependent that is + // itself disabled is skipped - it is not relying on `stem` right now. Sorted for a stable list. + static std::vector active_dependents_of(const std::string& stem) + { + std::vector result; + if (!big::g_lua_manager) + { + return result; + } + std::scoped_lock guard(big::g_lua_manager->m_module_lock); + for (const auto& module : big::g_lua_manager->m_modules) + { + if (!module) + { + continue; + } + const auto& deps = module->manifest().dependencies_no_version_number; + if (std::find(deps.begin(), deps.end(), stem) == deps.end()) + { + continue; + } + if (!mod_is_enabled(module->guid())) + { + continue; + } + result.push_back(display_name_from_stem(module->guid())); + } + std::sort(result.begin(), result.end()); + return result; + } + + // Body text for the dependency-block popup: names the mod that cannot be disabled, lists the enabled + // mods depending on it, and tells the player how to proceed. + // Body text for the dependency-block popup: lists the enabled mods depending on the one the player + // tried to disable, and tells them how to proceed. The intro/outro are kept short so they fit the + // dialog width on every locale (the wider CJK fonts overflow a long line); the blocked mod is + // identified by the dialog title and the toggle the player just clicked, so it is not repeated here. + static std::string build_dependency_message(const std::vector& dependents) + { + return build_list_message("These enabled mods depend on this one:", dependents, "Disable them first to disable this mod."); + } + static void* hook_MiscSettingsScreen_ctor(void* self, void* screen_manager, void* opened_from, void* profile_name) { // Reset state BEFORE running the original ctor: the original ctor immediately shows @@ -3069,11 +3167,27 @@ namespace big::mod_settings // this hook - the num-box handles its own arrow clicks and left/right natively. if (entry && entry->type() == typeid(bool)) { + const bool new_value = !entry->get_value_base(); + + // Block disabling a mod that other enabled mods still depend on: turning the mod's + // master "enabled" switch off would break them. Leave the toggle on and show an + // informational popup listing the dependents (its button just dismisses the popup). + if (matched_row.is_enabled_toggle && !new_value) + { + const std::vector dependents = active_dependents_of(matched_row.stem); + if (!dependents.empty()) + { + void* owner = *reinterpret_cast(reinterpret_cast(self) + sgg::gui_component_button_owner_offset); + void* screen_manager = owner ? *reinterpret_cast(reinterpret_cast(owner) + screen_manager_offset) : nullptr; + show_dependency_dialog(screen_manager, build_dependency_message(dependents)); + break; // do not disable; the toggle stays on + } + } + // Capture the session baseline before the first write so a later revert to // it (toggling off then on again) is recognised as "no net change". capture_restart_baseline(entry); - const bool new_value = !entry->get_value_base(); entry->set_value_base(new_value); set_toggle_graphic(self, new_value); From 326a09c4638fbbc8d13d7eb5a9f13ffa4709b73b Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:50:48 +0100 Subject: [PATCH 030/100] Fix heap corruption from cross-CRT dialog free (allocate MessageDialog via ucrtbase); tear down Mods-tab rows on category leave --- src/hades2/mod_settings/mod_settings.cpp | 39 +++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index f898f81..7e32f78 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2775,6 +2775,27 @@ namespace big::mod_settings } } + // Allocates from the GAME's CRT heap (ucrtbase _aligned_malloc). We hand the resulting block to the + // game as a screen (a MessageDialog): the game's ScreenManager frees removed screens with + // ucrtbase's _aligned_free. H2M links the static CRT (/MT), so its own _aligned_malloc would place + // the block in a different heap; the game's _aligned_free would then decode the (identically + // formatted) alignment header and call the process free on a block that heap never owned, corrupting + // the heap. Allocating the screen here, from the same CRT that will free it, keeps the pair matched. + // Returns nullptr if ucrtbase or the symbol is unavailable (caller then does not show the dialog). + static void* game_crt_aligned_malloc(std::size_t size, std::size_t alignment) + { + using aligned_malloc_fn = void*(__cdecl*)(std::size_t, std::size_t); + static aligned_malloc_fn fn = []() -> aligned_malloc_fn + { + if (HMODULE ucrt = GetModuleHandleW(L"ucrtbase.dll")) + { + return reinterpret_cast(GetProcAddress(ucrt, "_aligned_malloc")); + } + return nullptr; + }(); + return fn ? fn(size, alignment) : nullptr; + } + // Shows the native single-button message box (sgg::MessageDialog, the same box the game uses in the // main menu for save/file errors), modal over the options screen, with `title` as the heading and // `message` as the body. When confirm_closes_game is true the confirm button is captured so the @@ -2788,7 +2809,10 @@ namespace big::mod_settings { if (screen_manager && g_message_dialog_ctor && g_add_screen) { - void* dialog = _aligned_malloc(message_dialog_size, 8); + // Allocate from the game's CRT: the ScreenManager frees this screen with ucrtbase's + // _aligned_free (see game_crt_aligned_malloc). Our own rows are the opposite - we both + // allocate and free them - so they stay on H2M's CRT; only this game-freed screen must not. + void* dialog = game_crt_aligned_malloc(message_dialog_size, 8); if (dialog) { std::memset(dialog, 0, message_dialog_size); @@ -2970,6 +2994,19 @@ namespace big::mod_settings auto* screen = static_cast(self); const bool is_mods_tab = category_button && category_button == reinterpret_cast(screen->m_editor_options_button); + // Leaving the Mods tab for another category: tear our rows down FIRST, before the native + // category switch runs. The native switch only unlinks the outgoing category's mOptions entries + // from mComponents; our right-column value components are in mComponents but NOT mOptions (they + // are drawn, not paged), so the native teardown would leave them behind. They would then linger + // in mComponents on the other category - re-localized by a language change and walked by the + // native layout - which can corrupt unrelated widgets (e.g. a category button's label). Doing + // our own teardown here keeps mComponents clean for the native code; re-entering the tab rebuilds. + if (!is_mods_tab && !g_rows.empty()) + { + destroy_rows(screen); + exit_edit_mode(); + } + auto* result = big::g_hooking->get_original()(self, category_button, category_flag); show_mods_tab(screen); From b5cd9d31b44838f6dbcce7e4dd68afcb82fd0f68 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:51:59 +0100 Subject: [PATCH 031/100] Mods menu review fixes: free rows on close, harden restart-confirm guard, bound metadata registries --- src/hades2/mod_settings/config_api.cpp | 11 +++-- src/hades2/mod_settings/mod_settings.cpp | 58 ++++++++++++------------ 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 0b49e24..10fea17 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -607,11 +607,16 @@ namespace big::mod_settings void bind_config_api(sol::state_view& state, sol::table& lua_ext) { - // A fresh Lua state re-runs every mod's main.lua, so drop the previous state's opt-out set - // before those calls re-register it. (Per-mod metadata is cleared in load; opt_out is a - // standalone call with nothing else to hang the clear off, so it is reset here instead.) + // A fresh Lua state re-runs every mod's main.lua, so drop all per-mod registries before those + // calls re-register them. load() also clears its own guid, but a mod uninstalled since the last + // state would never call load again, so its stale entries would otherwise linger forever; the + // opt-out set has no load() to hang a per-guid clear off either. Clearing everything here keeps + // all four registries bounded to the currently-loaded mods. { std::scoped_lock lock(g_metadata_mutex); + g_setting_metadata.clear(); + g_appearance_order.clear(); + g_setting_default.clear(); g_opted_out_mods.clear(); } diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 7e32f78..0c1f676 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -376,6 +376,13 @@ namespace big::mod_settings // The native restart message box's (only) button; clicking it closes the game (restart). static GUIComponent* g_restart_confirm_button = nullptr; + // The restart message box itself (owner of g_restart_confirm_button). Used only to re-validate that + // a clicked button really is the live restart dialog's button before terminating: matching the + // button pointer alone would be fooled if that dialog were freed and another button reused its + // address. A genuine restart button's owner is this dialog; any rebuilt row's owner is the options + // screen, so it will not match. + static void* g_restart_dialog = nullptr; + // True once the restart prompt has been shown this menu session (so closing again proceeds). static bool g_restart_prompt_shown = false; @@ -2775,27 +2782,6 @@ namespace big::mod_settings } } - // Allocates from the GAME's CRT heap (ucrtbase _aligned_malloc). We hand the resulting block to the - // game as a screen (a MessageDialog): the game's ScreenManager frees removed screens with - // ucrtbase's _aligned_free. H2M links the static CRT (/MT), so its own _aligned_malloc would place - // the block in a different heap; the game's _aligned_free would then decode the (identically - // formatted) alignment header and call the process free on a block that heap never owned, corrupting - // the heap. Allocating the screen here, from the same CRT that will free it, keeps the pair matched. - // Returns nullptr if ucrtbase or the symbol is unavailable (caller then does not show the dialog). - static void* game_crt_aligned_malloc(std::size_t size, std::size_t alignment) - { - using aligned_malloc_fn = void*(__cdecl*)(std::size_t, std::size_t); - static aligned_malloc_fn fn = []() -> aligned_malloc_fn - { - if (HMODULE ucrt = GetModuleHandleW(L"ucrtbase.dll")) - { - return reinterpret_cast(GetProcAddress(ucrt, "_aligned_malloc")); - } - return nullptr; - }(); - return fn ? fn(size, alignment) : nullptr; - } - // Shows the native single-button message box (sgg::MessageDialog, the same box the game uses in the // main menu for save/file errors), modal over the options screen, with `title` as the heading and // `message` as the body. When confirm_closes_game is true the confirm button is captured so the @@ -2809,10 +2795,11 @@ namespace big::mod_settings { if (screen_manager && g_message_dialog_ctor && g_add_screen) { - // Allocate from the game's CRT: the ScreenManager frees this screen with ucrtbase's - // _aligned_free (see game_crt_aligned_malloc). Our own rows are the opposite - we both - // allocate and free them - so they stay on H2M's CRT; only this game-freed screen must not. - void* dialog = game_crt_aligned_malloc(message_dialog_size, 8); + // The game's ScreenManager owns and frees this screen (with _aligned_free) once it is + // dismissed. H2M's static /MT UCRT and the game's ucrtbase share the process heap, so this + // _aligned_malloc pairs safely with the game's _aligned_free - the same alloc/free split the + // num-box rows rely on (game factory allocates, destroy_rows frees). + void* dialog = _aligned_malloc(message_dialog_size, 8); if (dialog) { std::memset(dialog, 0, message_dialog_size); @@ -2854,10 +2841,12 @@ namespace big::mod_settings } // Capture the confirm button only when it should close the game; otherwise the native - // confirm behaviour (dismiss the dialog) is left in place. + // confirm behaviour (dismiss the dialog) is left in place. Also remember the dialog so the + // OnClicked hook can confirm the clicked button still belongs to it before terminating. if (confirm_closes_game) { g_restart_confirm_button = *reinterpret_cast(bytes + dialog_confirm_button_offset); + g_restart_dialog = dialog; } // Add at the END of the screen list so it draws on top of the options menu. @@ -2969,6 +2958,7 @@ namespace big::mod_settings g_restart_required = false; g_restart_prompt_shown = false; g_restart_confirm_button = nullptr; + g_restart_dialog = nullptr; g_restart_changes.clear(); g_restart_baselines.clear(); g_last_description_component = nullptr; @@ -3154,8 +3144,10 @@ namespace big::mod_settings // vectors is safe (this runs mid input iteration). static bool hook_GUIComponentButton_OnClicked(GUIComponent* self, std::uint64_t location) { - // Clicking the restart message box's button closes the game (forced restart). - if (self && self == g_restart_confirm_button) + // Clicking the restart message box's button closes the game (forced restart). Re-validate the + // button's owner is still the restart dialog so a rebuilt row that happened to reuse the freed + // button's address (if the dialog were ever dismissed without confirming) cannot trigger it. + if (self && self == g_restart_confirm_button && g_restart_dialog && *reinterpret_cast(reinterpret_cast(self) + sgg::gui_component_button_owner_offset) == g_restart_dialog) { big::g_hooking->get_original()(self, location); flush_native_settings(); @@ -3397,7 +3389,8 @@ namespace big::mod_settings // required, show the native message box and DO NOT run the original (veto the close): the box // is modal over the still-open options screen and its button closes the game. A restart-required // change must not be cancellable (that would require undoing the change), so the restart is - // forced. If the native dialog cannot be shown, the MessageBox fallback closes the game anyway. + // forced. If the native dialog cannot be built, the change is already saved to the mod's config + // (it applies on the next manual restart), so we just let the screen close normally. static void hook_MiscSettingsScreen_ExitScreen(void* self) { // Inside a mod's settings, Esc / controller B / the on-screen Back button steps up one level: @@ -3421,6 +3414,13 @@ namespace big::mod_settings } } + // The screen is really closing now. Tear our rows down first: the engine frees a MenuScreen's + // components through its reflection helper (which our rows are deliberately not registered in), + // not by walking mComponents, so on close it would neither free nor double-free them - they would + // just leak. destroy_rows is a no-op when g_rows is already empty (e.g. closing off the Mods tab). + destroy_rows(screen); + exit_edit_mode(); + big::g_hooking->get_original()(self); } From d4025674562768caf1085232849ed893198e5cca Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:37:21 +0100 Subject: [PATCH 032/100] Re-select the drilled-through row (mod or group) when backing out, at any depth --- src/hades2/mod_settings/mod_settings.cpp | 119 +++++++++++++++-------- 1 file changed, 77 insertions(+), 42 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 0c1f676..3c4af99 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -404,14 +404,22 @@ namespace big::mod_settings static std::string g_pending_section; static bool g_nav_reset_to_top = false; // Reset action: force a top (non-instant) rebuild next apply_nav - // Preserve the mod-list scroll position and highlighted mod across a drill-in/back-out. When the - // user opens a mod from a scrolled-down mod list, g_mod_list_start_index records that scroll offset - // and g_mod_list_focus_stem the opened mod; returning to the list restores the offset and re-focuses - // that mod's row (instead of snapping back to the first page). g_restore_mod_list_position gates the - // restore so a fresh tab entry still starts at the top. - static std::uint32_t g_mod_list_start_index = 0; - static std::string g_mod_list_focus_stem; - static bool g_restore_mod_list_position = false; + // Navigation restore stack: one entry per drill-in level (the mod list into a mod, or a section into + // a child group). Each records the parent view's scroll offset and the identity of the row drilled + // through, so backing out restores that scroll and re-selects that row instead of snapping to the + // top. focus_stem identifies a mod row (returning to the mod list); focus_section identifies a group + // row by its target section (returning to a parent section). g_pending_restore holds the entry + // popped by the current back-navigation for build_panel to consume. + struct NavRestore + { + std::uint32_t scroll_index = 0; + std::string focus_stem; + std::string focus_section; + }; + + static std::vector g_nav_stack; + static NavRestore g_pending_restore; + static bool g_has_pending_restore = false; // Freetext edit state (number/string settings). A click enters edit mode; typed input // is captured in the window procedure and applied on the game thread in the Update hook. @@ -2448,13 +2456,21 @@ namespace big::mod_settings } } - // The mod-list row for a given mod stem (its mod_entry row), or nullptr if not present. Used to - // re-focus the mod the user just backed out of when returning to a scrolled mod list. - static GUIComponent* mod_row_for_stem(const std::string& stem) + // The row a pending back-navigation should re-focus: the mod_entry row of the mod that was open + // (focus_stem set), or the group row that drills into the section that was open (focus_section set). + // Exactly one of the two fields is set per restore. Returns nullptr if that row is not in the freshly + // built view (e.g. it was removed since). + static GUIComponent* restore_target_row(const NavRestore& r) { for (const auto& row : g_rows) { - if (row.kind == RowKind::mod_entry && row.stem == stem && row.component) + if (!row.component) + { + continue; + } + const bool match = + !r.focus_stem.empty() ? (row.kind == RowKind::mod_entry && row.stem == r.focus_stem) : (!r.focus_section.empty() && row.kind == RowKind::group && row.target_section == r.focus_section); + if (match) { return row.component; } @@ -2529,19 +2545,19 @@ namespace big::mod_settings // Let the engine position, paginate and drive the scrollbar/arrows for the rows. // - // Returning to the mod list from a mod's settings is a real view change (not instant), which - // would otherwise snap to the top. If the user opened the mod from a scrolled-down list, restore - // that scroll offset so they land back where they were, on the mod they just left. - const bool restoring_list = !instant && g_restore_mod_list_position && g_view == View::mod_list; + // Backing out to a parent view (the mod list, or a parent section) is a real view change (not + // instant), which would otherwise snap to the top. If a restore is pending from the back-nav, + // restore that view's saved scroll offset so the user lands where they were. + const bool restoring = !instant && g_has_pending_restore; std::uint32_t start = 0; - if (instant || restoring_list) + if (instant || restoring) { // Clamp the preserved offset in case the row count shrank (e.g. a row became // hidden), keeping a full page in view where possible. const std::uint32_t row_count = static_cast(g_rows.size()); const std::uint32_t max_start = row_count > rows_per_page ? row_count - rows_per_page : 0; - const std::uint32_t desired = instant ? prev_start : g_mod_list_start_index; + const std::uint32_t desired = instant ? prev_start : g_pending_restore.scroll_index; start = desired > max_start ? max_start : desired; } screen->m_page_start_index = start; @@ -2571,14 +2587,14 @@ namespace big::mod_settings // Value displays are not laid out by the scroll pass; place them on their key rows now. sync_value_columns(); - // On a real view change (tab entry, drilling into a mod, going back), drop the cursor on the - // first row so it highlights immediately like a native category. Skipped on in-place refreshes - // so committing an edit or toggling "enabled" does not yank focus back to the top. When - // returning to a scrolled mod list, focus the mod the user backed out of (on the restored page) - // rather than the first row. + // On a real view change (tab entry, drilling in, going back), drop the cursor on the first row + // so it highlights immediately like a native category. Skipped on in-place refreshes so + // committing an edit or toggling "enabled" does not yank focus back to the top. When backing + // out, focus the row the user drilled through (the mod in the list, or the group in its parent + // section) rather than the first row. if (!instant) { - GUIComponent* restore_focus = restoring_list ? mod_row_for_stem(g_mod_list_focus_stem) : nullptr; + GUIComponent* restore_focus = restoring ? restore_target_row(g_pending_restore) : nullptr; if (restore_focus) { focus_row(screen, restore_focus); @@ -2589,9 +2605,9 @@ namespace big::mod_settings } } - if (restoring_list) + if (restoring) { - g_restore_mod_list_position = false; + g_has_pending_restore = false; } } @@ -2608,18 +2624,38 @@ namespace big::mod_settings const bool instant = !g_nav_reset_to_top && (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); g_nav_reset_to_top = false; - // Opening a mod from the mod list: remember where the list was scrolled and which mod was - // opened, so backing out returns to that page with that mod highlighted (g_view is still the - // old view here, so m_page_start_index is the mod list's own scroll offset). - if (g_view == View::mod_list && g_pending_view == View::mod_settings) + // Maintain the restore stack. A drill-in step (the mod list into a mod, or a section into a + // deeper child section) pushes the parent's scroll offset plus the identity of the row being + // drilled through; a back step (a mod out to the list, or a child section out to its parent) + // pops that entry for build_panel to restore. g_view is still the old (parent) view here, so + // m_page_start_index is the parent's own scroll offset. A same-view rebuild (instant: Reset or + // an "enabled" toggle) is neither, so it leaves the stack untouched. + const bool drilling_in = + (g_view == View::mod_list && g_pending_view == View::mod_settings) + || (g_view == View::mod_settings && g_pending_view == View::mod_settings && g_pending_section.rfind(g_view_section + ".", 0) == 0); + const bool backing_out = + (g_view == View::mod_settings && g_pending_view == View::mod_list) + || (g_view == View::mod_settings && g_pending_view == View::mod_settings && g_view_section.rfind(g_pending_section + ".", 0) == 0); + + if (drilling_in) { - g_mod_list_start_index = screen->m_page_start_index; - g_mod_list_focus_stem = g_pending_stem; + NavRestore r; + r.scroll_index = screen->m_page_start_index; + if (g_view == View::mod_list) + { + r.focus_stem = g_pending_stem; // the mod being opened + } + else + { + r.focus_section = g_pending_section; // the child section being opened (a group row's target) + } + g_nav_stack.push_back(std::move(r)); } - // Returning to the mod list from a mod's settings: restore that saved scroll/focus. - if (g_view == View::mod_settings && g_pending_view == View::mod_list) + else if (backing_out && !g_nav_stack.empty()) { - g_restore_mod_list_position = true; + g_pending_restore = g_nav_stack.back(); + g_nav_stack.pop_back(); + g_has_pending_restore = true; } g_view = g_pending_view; @@ -2929,8 +2965,6 @@ namespace big::mod_settings return result; } - // Body text for the dependency-block popup: names the mod that cannot be disabled, lists the enabled - // mods depending on it, and tells the player how to proceed. // Body text for the dependency-block popup: lists the enabled mods depending on the one the player // tried to disable, and tells them how to proceed. The intro/outro are kept short so they fit the // dialog width on every locale (the wider CJK fonts overflow a long line); the blocked mod is @@ -2950,11 +2984,10 @@ namespace big::mod_settings g_view_stem.clear(); g_view_section.clear(); g_pending_section.clear(); - g_nav_pending = false; - g_nav_reset_to_top = false; - g_restore_mod_list_position = false; - g_mod_list_start_index = 0; - g_mod_list_focus_stem.clear(); + g_nav_pending = false; + g_nav_reset_to_top = false; + g_nav_stack.clear(); + g_has_pending_restore = false; g_restart_required = false; g_restart_prompt_shown = false; g_restart_confirm_button = nullptr; @@ -3009,6 +3042,8 @@ namespace big::mod_settings g_view_stem.clear(); g_view_section.clear(); g_nav_pending = false; + g_nav_stack.clear(); // a fresh tab entry starts at the top of the mod list + g_has_pending_restore = false; exit_edit_mode(); build_panel(screen); } From f666df0dc6f4aff7051ee29e6b7e05cc2d382d29 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:56:25 +0100 Subject: [PATCH 033/100] Reset config settings for Chalk mods too via the entry's own stored default --- src/hades2/mod_settings/mod_settings.cpp | 44 ++++++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 3c4af99..e29cc1d 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -2664,12 +2665,35 @@ namespace big::mod_settings build_panel(screen, instant); } - // Restores the current mod's config entries (g_view_stem) to their config.lua defaults (see - // get_setting_default), saving each change and flagging any restart-required ones. Only ever - // resets the one mod whose settings are open - never every mod - so it is called only from the - // mod-settings view. Only settings bound via rom.mod_settings.load carry a captured default; - // anything else (raw rom.config or big::config) is left untouched. Returns true if any value - // actually changed. + // The serialized default of a config entry, read from the entry itself via the public + // write_description (whose last output line is "# Default value: "). Works for any entry + // regardless of who bound it, so it recovers defaults for Chalk-bound mods, which never went through + // rom.mod_settings.load and so have no captured default in get_setting_default. The serialized form + // uses the same converter as get_serialized_value, so it round-trips through set_serialized_value. + static std::optional entry_default_serialized(toml_v2::config_file::config_entry_base* entry) + { + if (!entry) + { + return std::nullopt; + } + std::ostringstream ss; + entry->write_description(ss); + const std::string text = ss.str(); + static const std::string marker = "# Default value: "; + const auto pos = text.rfind(marker); + if (pos == std::string::npos) + { + return std::nullopt; + } + return text.substr(pos + marker.size()); + } + + // Restores the current mod's config entries (g_view_stem) to their defaults, saving each change and + // flagging any restart-required ones. Only ever resets the one mod whose settings are open - never + // every mod - so it is called only from the mod-settings view. The default comes from the config.lua + // value captured by rom.mod_settings.load when available, and otherwise from the config entry's own + // stored default (so Chalk-bound mods, which never go through load, still reset). Returns true if any + // value actually changed. static bool reset_settings_to_defaults() { bool any_changed = false; @@ -2688,7 +2712,13 @@ namespace big::mod_settings } auto* e = entry.get(); - const auto def_val = get_setting_default(guid, def.m_section, def.m_key); + auto def_val = get_setting_default(guid, def.m_section, def.m_key); + if (!def_val) + { + // Not bound via rom.mod_settings.load (e.g. a Chalk mod): recover the default from + // the config entry itself. + def_val = entry_default_serialized(e); + } if (!def_val || e->get_serialized_value() == *def_val) { continue; From 4c7b5e71988419ceaa5f58dddc2ba9bc540ed321 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:19:26 +0100 Subject: [PATCH 034/100] Support localization tables for display_name, description, and enum labels --- src/hades2/mod_settings/config_api.cpp | 99 ++++++++++++++++++------ src/hades2/mod_settings/mod_settings.cpp | 82 ++++++++++++++++---- src/hades2/mod_settings/mod_settings.hpp | 17 +++- 3 files changed, 156 insertions(+), 42 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 10fea17..411157b 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -157,29 +157,79 @@ namespace big::mod_settings return std::string::npos; } - // Extracts a description string from a config.lua description value, which may be a plain string - // or a table with a `description` field (or `[1]` shorthand). - static std::string describe(const sol::object& desc) + static std::string serialize_option(const sol::object& v); // defined below + + // Parses a user-facing string field that is either a plain scalar or a localization table (keyed by + // the game's language folder codes, e.g. { en = "...", ["zh-TW"] = "..." }). A scalar is stored under + // the empty key; a table contributes one entry per string-keyed string value. An empty or absent + // value yields an empty map (i.e. no override). + static localized_text parse_localized(const sol::object& o) + { + localized_text out; + if (o.is()) + { + o.as().for_each( + [&out](const sol::object& k, const sol::object& v) + { + if (k.get_type() == sol::type::string && v.get_type() == sol::type::string) + { + out[k.as()] = v.as(); + } + }); + return out; + } + const std::string s = serialize_option(o); + if (!s.empty()) + { + out[""] = s; + } + return out; + } + + // Resolves a localized string to a single language-independent value for on-disk use (the .cfg + // comment), which is not re-written per language: English, then the unlocalized value, then any + // entry. The in-game menu resolves to the live game language separately at render time. + static std::string localized_fallback(const localized_text& t) + { + if (t.empty()) + { + return {}; + } + if (const auto it = t.find("en"); it != t.end()) + { + return it->second; + } + if (const auto it = t.find(""); it != t.end()) + { + return it->second; + } + return t.begin()->second; + } + + // Extracts the (possibly localized) description from a config.lua description value, which may be a + // plain string, or a rich table with a `description` field (or `[1]` shorthand) that is itself a + // plain string or a localization table. + static localized_text describe(const sol::object& desc) { if (desc.get_type() == sol::type::string) { - return desc.as(); + return parse_localized(desc); } if (desc.is()) { sol::table t = desc.as(); sol::object as_field = t["description"]; - if (as_field.get_type() == sol::type::string) + if (as_field.valid() && as_field != sol::lua_nil) { - return as_field.as(); + return parse_localized(as_field); } sol::object as_first = t[1]; - if (as_first.get_type() == sol::type::string) + if (as_first.valid() && as_first != sol::lua_nil) { - return as_first.as(); + return parse_localized(as_first); } } - return ""; + return {}; } // True if a config.lua description table declares `restart_required = true`. @@ -233,12 +283,10 @@ namespace big::mod_settings setting_metadata m; m.description = describe(desc); - // Display-name override (`display_name`); empty -> the menu prettifies the key. + // Display-name override (`display_name`); empty -> the menu prettifies the key. May be a plain + // string or a localization table. sol::object display_name = desc["display_name"]; - if (display_name.get_type() == sol::type::string) - { - m.name = display_name.as(); - } + m.name = parse_localized(display_name); sol::object min_field = desc["min"]; if (min_field.get_type() == sol::type::number) @@ -265,12 +313,17 @@ namespace big::mod_settings { return serialize_option(v); }); - read_list(desc["labels"], - m.labels, - [](const sol::object& v) - { - return v.get_type() == sol::type::string ? v.as() : serialize_option(v); - }); + // Enum option display labels (parallel to `values`); each may be a plain string or a + // localization table. + if (sol::object labels_obj = desc["labels"]; labels_obj.is()) + { + sol::table lt = labels_obj.as(); + for (std::size_t i = 1; i <= lt.size(); ++i) + { + sol::object label = lt[i]; + m.labels.push_back(parse_localized(label)); + } + } sol::object order_field = desc["order"]; if (order_field.get_type() == sol::type::number) @@ -440,15 +493,15 @@ namespace big::mod_settings bind_defaults(cf, value_obj.as(), desc, section + "." + key, meta_out, defaults_out); break; case sol::type::boolean: - cf->bind(section, key, value_obj.as(), describe(desc)); + cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); default_any = std::any(value_obj.as()); break; case sol::type::number: - cf->bind(section, key, value_obj.as(), describe(desc)); + cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); default_any = std::any(value_obj.as()); break; case sol::type::string: - cf->bind(section, key, value_obj.as(), describe(desc)); + cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); default_any = std::any(value_obj.as()); break; default: continue; diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index e29cc1d..cd9a9ad 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1749,14 +1749,54 @@ namespace big::mod_settings g_restart_baselines.try_emplace(key, entry->get_serialized_value()); } + // The current game display-language folder code (e.g. "en", "zh-TW"), or "" if unavailable. Read from + // sgg::ConfigOptions::Language (an eastl SSO string whose code chars sit at offset 0, null-terminated). + static std::string current_language_code() + { + return g_config_language ? std::string(g_config_language) : std::string(); + } + + // Resolves a localized string to the current game language: the entry for the current language code, + // then English, then the unlocalized value (empty key), then any entry. A plain (unlocalized) string + // is stored as the single empty-key entry and returned as-is. Returns "" when there is nothing to + // show. Resolution happens here (render time), so re-entering the tab after a language change picks + // up the new language. + static std::string resolve_localized(const localized_text& t) + { + if (t.empty()) + { + return {}; + } + if (t.size() == 1) + { + return t.begin()->second; // one entry (a plain value, or the only language provided) + } + if (const auto it = t.find(current_language_code()); it != t.end()) + { + return it->second; + } + if (const auto it = t.find("en"); it != t.end()) + { + return it->second; + } + if (const auto it = t.find(""); it != t.end()) + { + return it->second; + } + return t.begin()->second; + } + // The friendly display name for a setting: the author's `display_name` override when provided, // otherwise the prettified key. Mirrors how the setting rows are labelled. static std::string setting_display_name(const std::string& stem, const std::string& section, const std::string& key) { const auto meta = get_setting_metadata(stem, section, key); - if (meta && !meta->name.empty()) + if (meta) { - return meta->name; + if (std::string name = resolve_localized(meta->name); !name.empty()) + { + return name; + } } return key_to_display(key); } @@ -2059,7 +2099,8 @@ namespace big::mod_settings { continue; } - const std::string glabel = escape_markup((gmeta && !gmeta->name.empty()) ? gmeta->name : key_to_display(it.key)); + const std::string gname = gmeta ? resolve_localized(gmeta->name) : std::string{}; + const std::string glabel = escape_markup(!gname.empty() ? gname : key_to_display(it.key)); if (auto* row = make_text_row(screen, glabel.c_str(), disabled)) { PanelRow pr{row, RowKind::group, stem, {}}; @@ -2067,7 +2108,7 @@ namespace big::mod_settings pr.target_section = it.child_section; if (gmeta) { - pr.description = gmeta->description; + pr.description = resolve_localized(gmeta->description); } g_rows.push_back(std::move(pr)); } @@ -2083,7 +2124,8 @@ namespace big::mod_settings { continue; } - const std::string label = escape_markup((meta && !meta->name.empty()) ? meta->name : key_to_display(key)); + const std::string mname = meta ? resolve_localized(meta->name) : std::string{}; + const std::string label = escape_markup(!mname.empty() ? mname : key_to_display(key)); // An enum (metadata `values`) renders as a native number box cycling its label list; a // numeric setting with author-declared min AND max renders as a native number box over @@ -2102,8 +2144,20 @@ namespace big::mod_settings int enum_index = 0; if (is_enum) { - enum_values = meta->values; - enum_labels = (meta->labels.size() == enum_values.size()) ? meta->labels : enum_values; + enum_values = meta->values; + // Labels parallel the values when the author supplied a full set (each resolved to the + // current language); otherwise the raw values double as their own labels. + if (meta->labels.size() == enum_values.size()) + { + for (const auto& lbl : meta->labels) + { + enum_labels.push_back(resolve_localized(lbl)); + } + } + else + { + enum_labels = enum_values; + } const std::string cur = entry->get_serialized_value(); for (int i = 0; i < static_cast(enum_values.size()); ++i) { @@ -2157,8 +2211,10 @@ namespace big::mod_settings pr.disabled = disabled; pr.is_enabled_toggle = is_enabled_row; pr.value_component = value; - // Prefer the author's metadata description; fall back to the .cfg comment text. - pr.description = (meta && !meta->description.empty()) ? meta->description : entry->m_description.m_description; + // Prefer the author's metadata description (resolved to the current language); fall back + // to the .cfg comment text. + const std::string mdesc = meta ? resolve_localized(meta->description) : std::string{}; + pr.description = !mdesc.empty() ? mdesc : entry->m_description.m_description; if (is_enum) { @@ -2756,12 +2812,8 @@ namespace big::mod_settings // (English) mod/setting entries from wrapping mid-line. static bool current_language_is_cjk() { - if (!g_config_language) - { - return false; - } - const char* code = g_config_language; // eastl SSO string: null-terminated code chars at offset 0 - return std::strncmp(code, "zh", 2) == 0 || std::strncmp(code, "ja", 2) == 0 || std::strncmp(code, "ko", 2) == 0; + const std::string code = current_language_code(); + return code.rfind("zh", 0) == 0 || code.rfind("ja", 0) == 0 || code.rfind("ko", 0) == 0; } // Builds a locale-aware popup body: an intro line, a blank line, one line per list entry, a blank diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 8b750ce..b937543 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -9,6 +10,13 @@ namespace big::mod_settings void register_hooks(); void bind_config_api(sol::state_view& state, sol::table& lua_ext); + // A user-facing string an author may write in config.lua either plainly ("Enable feature") or as a + // localization table keyed by the game's language folder codes ({ en = "...", de = "...", + // ["zh-TW"] = "..." }). Stored as language-code -> text, with a plain string kept under the empty + // key. The settings menu resolves it to the current game language at render time (see + // resolve_localized), falling back to English then any entry. + using localized_text = std::map; + // Author-declared metadata for a single setting, extracted from its config.lua description // table by rom.mod_settings.load and consulted by the settings menu. Only settings whose // description is a rich table have an entry; the rest fall back to type-based rendering. Every @@ -16,8 +24,8 @@ namespace big::mod_settings // itself is inferred from the value and `values`. All fields are optional (see the has_* flags). struct setting_metadata { - std::string name; // display-name override (empty -> prettified key) - std::string description; // same text written to the .cfg comment + localized_text name; // display-name override (empty -> prettified key) + localized_text description; // same text written to the .cfg comment bool has_min = false; double min = 0.0; @@ -27,9 +35,10 @@ namespace big::mod_settings double step = 0.0; // Enum options: serialized option values and parallel display labels (labels default to - // the values when omitted). Serialized form matches the config entry's serialization. + // the values when omitted). Serialized form matches the config entry's serialization; each + // label may be localized. std::vector values; - std::vector labels; + std::vector labels; bool has_order = false; double order = 0.0; // author-declared sort key (lower first); unset -> map order From 705d2e159db740623ada1e4df2843c0caf85fd77 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:46:25 +0100 Subject: [PATCH 035/100] Add LuaCATS config schema + authoring guide and document rom.mod_settings.load/opt_out for IDE hints --- .../tables/definitions/rom.mod_settings.lua | 14 ++++++ docs/lua/tables/rom.mod_settings.md | 30 ++++++++++++ docs/mod_settings/README.md | 43 ++++++++++++++++ docs/mod_settings/config_schema.lua | 49 +++++++++++++++++++ src/hades2/mod_settings/config_api.cpp | 28 +++++++---- 5 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 docs/lua/tables/definitions/rom.mod_settings.lua create mode 100644 docs/lua/tables/rom.mod_settings.md create mode 100644 docs/mod_settings/README.md create mode 100644 docs/mod_settings/config_schema.lua diff --git a/docs/lua/tables/definitions/rom.mod_settings.lua b/docs/lua/tables/definitions/rom.mod_settings.lua new file mode 100644 index 0000000..23270bb --- /dev/null +++ b/docs/lua/tables/definitions/rom.mod_settings.lua @@ -0,0 +1,14 @@ +---@meta mod_settings + +---@class (exact) rom.mod_settings + +-- Loads a mod's config.lua and registers its settings under the Mods tab of the in-game Options +-- menu, returning a live read/write proxy over the config. When using this, you do not need to depend on `Chalk`. +---@param config_lua string Path, relative to the mod's folder, of the config.lua that returns `config, configDesc`. +---@return table # A live read/write proxy over the mod's config; index it to read a setting and assign to write one. +function mod_settings.load(config_lua) end + +-- Excludes the calling mod from the in-game mod settings menu: it stays listed but will begreyed out and +-- cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod +-- should not be edited in-game. Works with Chalk or rom.mod_settings.load. +function mod_settings.opt_out() end diff --git a/docs/lua/tables/rom.mod_settings.md b/docs/lua/tables/rom.mod_settings.md new file mode 100644 index 0000000..528c397 --- /dev/null +++ b/docs/lua/tables/rom.mod_settings.md @@ -0,0 +1,30 @@ +# Table: rom.mod_settings + +## Functions (2) + +### `load(config_lua)` + +Loads a mod's config.lua and registers its settings under the Mods tab of the in-game Options menu, returning a +live read/write proxy over the config. When using this, you do not need to depend on `Chalk`. + +- **Parameters:** + - `config_lua` (string): Path, relative to the mod's folder, of the config.lua that returns `config, configDesc`. + +- **Returns:** + - `table`: A live read/write proxy over the mod's config; index it to read a setting and assign to write one. + +**Example Usage:** +```lua +table = rom.mod_settings.load(config_lua) +``` + +### `opt_out()` + +Excludes the calling mod from the in-game mod settings menu: it stays listed but will begreyed out and +cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod +should not be edited in-game. Works with Chalk or rom.mod_settings.load. + +**Example Usage:** +```lua +rom.mod_settings.opt_out() +``` diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md new file mode 100644 index 0000000..58f26af --- /dev/null +++ b/docs/mod_settings/README.md @@ -0,0 +1,43 @@ +# In-game mod settings - IDE schema & hints + +Hell2Modding renders each mod's config file as a tab in the game's Options screen. Mods declare how +their settings look and read/write their values through a `config.lua` that returns two tables: + +- `config` - the default values (and the live values once loaded). +- `configDesc` - the description/metadata for each setting (labels, help text, ranges, enums, ...). + +This folder ships [LuaCATS](https://luals.github.io/wiki/annotations/) definitions +([`config_schema.lua`](./config_schema.lua)) so that VS Code gives you **autocomplete** and **hover +documentation** while you write `configDesc`, plus **field type checking** on settings you annotate +directly (see below). + +## Enabling it in VS Code + +1. Install the [Lua extension](https://marketplace.visualstudio.com/items?itemName=sumneko.lua) for VS Code. +2. In the extension's settings, add the folder containing the `config_schema.lua` to the `workspace.library` array. +3. Annotate the `configDesc` table with `---@type mod_settings.config_desc`. + +## Field reference + +Hover any field in the editor for its documentation. The available fields on a setting description are: + +| Field | Type | Purpose | +| --- | --- | --- | +| `display_name` | string \| localization table | Row label (defaults to a prettified key). | +| `description` | string \| localization table | Help text in the description box. Keep each line ~35 chars to leave space for free-text input strings. | +| `min`/`max` | number | Numeric bounds. If both are present the input will turn into a slider (such as for volume control). | +| `step` | number | Slider/number step size (default 1). Will clamp user input automatically. | +| `values` | array | Enum: the values stored in the `.cfg` file. If present, the input will turn into a cycler (such as for the selected display). | +| `labels` | array of (string \| localization table) | Display labels parallel to `values`, only used in the in-game mod menu. | +| `order` | number | Sort key for custom ordering config entries in the menu, lower first. | +| `hidden` | boolean | Hide the setting from the menu entirely. | +| `freetext` | boolean | Force a bounded number to be a free-text entry instead of a slider. | +| `restart_required` | boolean | Force the user to restart the game when this setting is changed. | +| `show_as_percentage` | boolean | Append "%" to the value. | +| `is_percentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | + +## Localization tables + +Any `display_name`, `description`, or `labels` entry may be a table keyed by the game's language folder +codes (`en`, `de`, `el`, `es`, `fr`, `it`, `ja`, `ko`, `pl`, `pt-BR`, `ru`, `tr`, `uk`, `zh-CN`, +`zh-TW`). The menu resolves it to the current game language, falling back to English. diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua new file mode 100644 index 0000000..56fd788 --- /dev/null +++ b/docs/mod_settings/config_schema.lua @@ -0,0 +1,49 @@ +---@meta + +--- A user-facing string. Either a plain string, or a localization table keyed by the game's language +--- folder codes (en, de, el, es, fr, it, ja, ko, pl, pt-BR, ru, tr, uk, zh-CN, zh-TW). It is resolved +--- to the current game language when the menu is shown, falling back to English. +--- Example: `{ en = "Difficulty", de = "Schwierigkeit" }` +---@alias mod_settings.localized_string string | table + +--- Describes how a config option appears in the in-game mod settings menu. Every field is optional. The +--- widget type is inferred from the setting's config value (a boolean becomes a toggle; a number with `min` +--- and `max` becomes a slider; a value with `values` becomes a cycler; anything else is a free-text field). +---@class (exact) mod_settings.setting_description +--- Help text shown at the bottom of the options menu while the config rows is highlighted. Recommended to keep +--- to about 35 characters so it leaves enough space for free-text input strings. +---@field description? mod_settings.localized_string +--- Row label. Defaults to a prettified version of the config key (e.g. `myCool_Setting` -> "My Cool Setting"). +---@field display_name? mod_settings.localized_string +--- Lower bound for a numeric setting. Combined with `max`, the setting renders as a slider. +---@field min? number +--- Upper bound for a numeric setting. Combined with `min`, the setting renders as a slider. +---@field max? number +--- Step between values for a slider and free-text number inputs. Defaults to 1. +--- H2M will clamp the input automatically. +---@field step? number +--- Enum options: the values actually stored in the .cfg file. +--- Providing this makes the setting a cycler over these options. +---@field values? (string | number | boolean)[] +--- Display labels shown for each entry of `values` (same order, same number of entries). Each label may be a +--- localization table. When omitted, the raw values are shown in the cycler. +---@field labels? mod_settings.localized_string[] +--- Sort key for custom ordering config entries in the menu, lower first. +--- When omitted, rows keep the order they are defined in the default config you provide. +---@field order? number +--- Hide this setting from the menu entirely. +---@field hidden? boolean +--- Force a bounded number (one with `min` and `max`) to a free-text text field instead of a slider. +---@field freetext? boolean +--- Mark that changing this setting requires a game restart. The menu forces the player +--- to restart when they leave the mod menu after changing it. +---@field restart_required? boolean +--- Append "%" to the displayed value. +---@field show_as_percentage? boolean +--- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). +---@field is_percentage? boolean + +--- Each entry in `configDesc` can either be simple key:description pair, or be a nested table using the allowed +--- parameters to enhance the way it is displayed in the in-game mod menu. The underlying .cfg file contents are +--- not changed by this format. +---@alias mod_settings.config_desc table diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 411157b..aef6533 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -524,13 +524,18 @@ namespace big::mod_settings } } - // rom.mod_settings.load(config_lua): native replacement for chalk.auto. Uses the calling mod - // (this_environment) to derive its /.cfg path and create a native - // config_file owned by that mod, loads the mod's config.lua, binds its defaults/descriptions - // into that config_file, records any restart-required settings, and returns a live read/write - // proxy over the config. + // Lua API: Function + // Table: mod_settings + // Name: load + // Param: config_lua: string: Path, relative to the mod's folder, of the config.lua that returns `config, configDesc`. + // Returns: table: A live read/write proxy over the mod's config; index it to read a setting and assign to write one. + // Loads a mod's config.lua and registers its settings under the Mods tab of the in-game Options menu, returning a live + // read/write proxy over the config. When using this, you do not need to depend on `Chalk`. static sol::object load(sol::this_state ts, sol::this_environment this_env, const std::string& config_lua) { + // Uses the calling mod (this_environment) to derive its /.cfg path and create + // a native config_file owned by that mod, loads the mod's config.lua, binds its defaults and + // descriptions into that config_file, records any restart-required settings, and returns the proxy. if (!this_env) { return sol::lua_nil; @@ -638,13 +643,16 @@ namespace big::mod_settings return sol::make_object(ts, mod_config_proxy{cf.get(), "config"}); } - // rom.mod_settings.opt_out(): the calling mod asks not to be configured through the in-game mod - // settings menu. The mod is still listed there (removing it would look like a missing/broken mod), - // but its row is greyed, cannot be opened, and shows a note pointing the user back to the mod's own - // description for configuration. Keyed by the calling mod's guid (which matches its config-file - // stem), so it applies however the mod manages its config (Chalk or rom.mod_settings.load). + // Lua API: Function + // Table: mod_settings + // Name: opt_out + // Excludes the calling mod from the in-game mod settings menu: it stays listed but greyed out and + // cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod + // should not be edited in-game. Works with Chalk or rom.mod_settings.load. static void opt_out(sol::this_environment this_env) { + // Keyed by the calling mod's guid (which matches its config-file stem), so the menu can grey the + // matching row however the mod manages its config. if (!this_env) { return; From 005324263e2a60b3e7111830141ce7a270b09a0c Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:02:52 +0100 Subject: [PATCH 036/100] Add per-setting editable_context (main_menu/in_save) with read-only greying by menu context --- docs/mod_settings/README.md | 1 + docs/mod_settings/config_schema.lua | 4 + src/hades2/mod_settings/config_api.cpp | 21 +++++ src/hades2/mod_settings/mod_settings.cpp | 108 +++++++++++++++++++++++ src/hades2/mod_settings/mod_settings.hpp | 20 +++++ 5 files changed, 154 insertions(+) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index 58f26af..b30cba8 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -33,6 +33,7 @@ Hover any field in the editor for its documentation. The available fields on a s | `hidden` | boolean | Hide the setting from the menu entirely. | | `freetext` | boolean | Force a bounded number to be a free-text entry instead of a slider. | | `restart_required` | boolean | Force the user to restart the game when this setting is changed. | +| `editable_context` | `"any"` \| `"main_menu"` \| `"in_save"` | If this setting can be changed only in the main menu, only in a save, or in both. When the current context does not match, the row is shown read-only with a note. The "enabled" setting and any `restart_required` settings are always treated as `"main_menu"`. Defaults to `"any"`. | | `show_as_percentage` | boolean | Append "%" to the value. | | `is_percentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 56fd788..b4c2c85 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -38,6 +38,10 @@ --- Mark that changing this setting requires a game restart. The menu forces the player --- to restart when they leave the mod menu after changing it. ---@field restart_required? boolean +--- If this setting can be changed only in the main menu, only in a save, or in both. +--- When the current context does not match, the row is shown read-only with a note. +--- The "enabled" setting and any `restart_required` settings are always treated as `"main_menu"`. +---@field editable_context? "any" | "main_menu" | "in_save" --- Append "%" to the displayed value. ---@field show_as_percentage? boolean --- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index aef6533..c31a17e 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -357,6 +357,27 @@ namespace big::mod_settings m.restart_required = description_requires_restart(desc); + // When the setting may be changed relative to a loaded save (`editable_context`). Accepts + // "any" (default), "main_menu", or "in_save"; anything else is ignored (stays `any`). The + // menu forces the master "enabled" toggle and restart_required settings to main_menu + // regardless, so authors need only annotate the in-between cases. + if (sol::object ctx_field = desc["editable_context"]; ctx_field.get_type() == sol::type::string) + { + const std::string ctx = ctx_field.as(); + if (ctx == "main_menu") + { + m.context = editable_context::main_menu; + } + else if (ctx == "in_save") + { + m.context = editable_context::in_save; + } + else if (ctx == "any") + { + m.context = editable_context::any; + } + } + return m; } diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index cd9a9ad..69d26ac 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1948,6 +1948,70 @@ namespace big::mod_settings return big::string::to_lower(key) == "enabled"; } + // True when the options screen was opened during gameplay (a save is loaded), false when opened + // from the main menu. Captured from the MiscSettingsScreen constructor's "opened from" argument + // (see hook_MiscSettingsScreen_ctor); used to grey out context-restricted setting rows. + static bool g_opened_in_game = false; + + // The MiscSettingsScreen ctor's "opened from" argument is the opening screen (sgg::MenuScreen*): + // a MainMenuScreen when opened from the main menu, a PauseScreen when opened in-game (the only two + // call sites in the engine). GameScreen::GetType (virtual, vtable slot 10 - a `mov eax,imm; ret` + // stub, so calling it is side-effect-free and ASLR-independent) returns the screen's ScreenType; + // Pause identifies the in-game opener. + static constexpr std::size_t game_screen_get_type_vtable_slot = 10; + static constexpr int screen_type_pause = 0x10'00'03; // sgg::ScreenType::Pause + + static bool opener_indicates_in_game(void* opened_from) + { + if (!opened_from) + { + return false; + } + void** vtable = *reinterpret_cast(opened_from); + auto get_type = reinterpret_cast(vtable[game_screen_get_type_vtable_slot]); + return get_type(opened_from) == screen_type_pause; + } + + // The context in which a setting may actually be changed. Authors declare it, but the master + // "enabled" toggle and any restart_required setting are forced to main_menu because neither can + // take effect on the live save. + static editable_context effective_editable_context(const std::optional& meta, bool is_enabled_toggle) + { + if (is_enabled_toggle) + { + return editable_context::main_menu; + } + if (meta && meta->restart_required) + { + return editable_context::main_menu; + } + return meta ? meta->context : editable_context::any; + } + + // True when a setting cannot be changed in the current screen context (main-menu vs in-game), so + // its row is shown read-only with an explanatory note instead of an editable widget. + static bool is_context_restricted(editable_context ctx) + { + switch (ctx) + { + case editable_context::main_menu: return g_opened_in_game; // main-menu-only, greyed while in a save + case editable_context::in_save: return !g_opened_in_game; // in-save-only, greyed at the main menu + default: return false; // any + } + } + + // The note shown in the description box for a row that is read-only because of its editable + // context. Empty for `any` (never restricted). + static std::string context_note(editable_context ctx) + { + switch (ctx) + { + case editable_context::main_menu: return "This setting can only be changed from the main menu."; + case editable_context::in_save: return "This setting can only be changed while a save is loaded."; + default: return {}; + } + } + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. // Leaf entries render as setting rows (bool -> toggle, enum/bounded number -> num box, else a // freetext value); each direct child section renders as a group row that drills into it. At the @@ -2172,6 +2236,45 @@ namespace big::mod_settings GUIComponent* row = nullptr; GUIComponent* value = nullptr; bool built_slider = false; + + // A setting whose editable context does not match the current screen (main-menu vs + // in-game) is shown read-only: its current value in a greyed key+value row that still + // takes focus, so the description box can explain where to change it. Edits are blocked + // by pr.disabled in the row handlers. Skipped when the mod is disabled, whose own greying + // already covers every row. + const editable_context ctx = effective_editable_context(meta, is_enabled_row); + if (!disabled && is_context_restricted(ctx)) + { + std::string vtext; + if (entry->type() == typeid(bool)) + { + vtext = entry->get_value_base() ? "true" : "false"; + } + else if (is_enum && enum_index >= 0 && enum_index < static_cast(enum_labels.size())) + { + vtext = enum_labels[enum_index]; + } + else if (is_stepper) + { + vtext = format_setting_display(entry->get_value_base(), meta->show_as_percentage, meta->is_percentage, step); + } + else + { + vtext = truncate_value(entry->get_serialized_value()); + } + + if (auto* ro_row = make_text_row(screen, label.c_str(), /*disabled*/ true, /*block_input*/ false)) + { + PanelRow pr{ro_row, RowKind::setting, stem, key, entry}; + pr.disabled = true; // blocks every edit path (click / slider / num-box) via the row handlers + pr.is_enabled_toggle = is_enabled_row; + pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); + pr.description = context_note(ctx); + g_rows.push_back(pr); + } + continue; + } + if (entry->type() == typeid(bool)) { row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), disabled); @@ -3081,6 +3184,11 @@ namespace big::mod_settings g_prompt_cancel_label.clear(); exit_edit_mode(); + // Record whether the screen was opened during gameplay (a save loaded) or from the main menu, + // so context-restricted rows can be greyed. Must be set before the original ctor runs, which + // shows the last-viewed category and may build our panel via DoShowCategory. + g_opened_in_game = opener_indicates_in_game(opened_from); + // The engine constructor returns `this`; forward it unchanged. auto* screen = static_cast(big::g_hooking->get_original()(self, screen_manager, opened_from, profile_name)); diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index b937543..14d1bb6 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -17,6 +17,22 @@ namespace big::mod_settings // resolve_localized), falling back to English then any entry. using localized_text = std::map; + // When a setting may be changed, relative to whether a save is loaded. The Lua state is recreated + // when a save is loaded from the main menu, so init-time changes (GameData edits, function patches) + // only take effect if made before that point, while some settings only apply to a live run. The + // settings menu greys a row (read-only, with a note) when the current context does not match: + // - any: editable anywhere (default; live-read settings). + // - main_menu: only from the main menu (greyed while a save is loaded). Forced for a mod's master + // "enabled" toggle and for any restart_required setting. + // - in_save: only while a save is loaded (greyed at the main menu). + // Authors declare this per setting via `editable_context = "main_menu" | "in_save" | "any"`. + enum class editable_context + { + any, + main_menu, + in_save, + }; + // Author-declared metadata for a single setting, extracted from its config.lua description // table by rom.mod_settings.load and consulted by the settings menu. Only settings whose // description is a rich table have an entry; the rest fall back to type-based rendering. Every @@ -47,6 +63,10 @@ namespace big::mod_settings bool restart_required = false; // change only takes effect after a game restart bool freetext = false; // force a bounded number to freetext entry (not the stepper) + // When this setting may be changed relative to a loaded save (see editable_context). Default + // `any`; forced to `main_menu` for the master "enabled" toggle and for restart_required settings. + editable_context context = editable_context::any; + // Number-display options (mainly for the slider). is_percentage shows a 0..1 value as 0..100 and // appends "%"; show_as_percentage only appends "%" (no scaling). Setting show_as_percentage in // addition to is_percentage is a no-op. The stored config value is never modified by either. From e0968a0483b3931dbe88e751ab61a87142748cd0 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:13:41 +0100 Subject: [PATCH 037/100] Keep the partial final page's offset when backing out of a mod --- src/hades2/mod_settings/mod_settings.cpp | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 69d26ac..bc8ae34 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2713,12 +2713,14 @@ namespace big::mod_settings std::uint32_t start = 0; if (instant || restoring) { - // Clamp the preserved offset in case the row count shrank (e.g. a row became - // hidden), keeping a full page in view where possible. - const std::uint32_t row_count = static_cast(g_rows.size()); - const std::uint32_t max_start = row_count > rows_per_page ? row_count - rows_per_page : 0; - const std::uint32_t desired = instant ? prev_start : g_pending_restore.scroll_index; - start = desired > max_start ? max_start : desired; + // Restore the exact offset the view had. Only clamp when it now points past the last row + // (the row count shrank, e.g. a row became hidden), and then to the first index of the + // last page - so a partial final page (fewer than rows_per_page rows) keeps its own offset + // instead of being pulled up into a full page of rows. + const std::uint32_t row_count = static_cast(g_rows.size()); + const std::uint32_t last_page_start = row_count > 0 ? ((row_count - 1) / rows_per_page) * rows_per_page : 0; + const std::uint32_t desired = instant ? prev_start : g_pending_restore.scroll_index; + start = desired > last_page_start ? last_page_start : desired; } screen->m_page_start_index = start; screen->m_options_per_page = rows_per_page; From 8442127bdbb4ab3d5e32d04e1abcb29ee359417e Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:55:50 +0100 Subject: [PATCH 038/100] Dynamic settings, action buttons --- docs/mod_settings/README.md | 89 +- docs/mod_settings/config_schema.lua | 55 +- src/hades2/mod_settings/config_api.cpp | 625 +++++-- src/hades2/mod_settings/mod_settings.cpp | 1978 +++++++++++++--------- src/hades2/mod_settings/mod_settings.hpp | 126 +- src/hades2/mod_settings/sgg_gui.hpp | 50 +- 6 files changed, 1874 insertions(+), 1049 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index b30cba8..b076977 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -23,19 +23,92 @@ Hover any field in the editor for its documentation. The available fields on a s | Field | Type | Purpose | | --- | --- | --- | -| `display_name` | string \| localization table | Row label (defaults to a prettified key). | -| `description` | string \| localization table | Help text in the description box. Keep each line ~35 chars to leave space for free-text input strings. | -| `min`/`max` | number | Numeric bounds. If both are present the input will turn into a slider (such as for volume control). | -| `step` | number | Slider/number step size (default 1). Will clamp user input automatically. | -| `values` | array | Enum: the values stored in the `.cfg` file. If present, the input will turn into a cycler (such as for the selected display). | -| `labels` | array of (string \| localization table) | Display labels parallel to `values`, only used in the in-game mod menu. | -| `order` | number | Sort key for custom ordering config entries in the menu, lower first. | -| `hidden` | boolean | Hide the setting from the menu entirely. | +| `display_name` | string \| localization table \| callback | Row label (defaults to a prettified key). | +| `description` | string \| localization table \| callback | Help text in the description box. Keep each line ~35 chars to leave space for free-text input strings. | +| `min`/`max` | number \| callback | Numeric bounds. If both are present the input will turn into a slider (such as for volume control). | +| `step` | number \| callback | Slider/number step size (default 1). Will clamp user input automatically. | +| `values` | array \| callback | Enum: the values stored in the `.cfg` file. If present, the input will turn into a cycler (such as for the selected display). | +| `labels` | array of (string \| localization table) \| callback | Display labels parallel to `values`, only used in the in-game mod menu. | +| `order` | number \| callback | Sort key for custom ordering config entries in the menu, lower first. | +| `hidden` | boolean | Hide the setting from the menu entirely. Static only - use `disabled` for a condition that changes while the menu is open. | +| `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. See below. | | `freetext` | boolean | Force a bounded number to be a free-text entry instead of a slider. | | `restart_required` | boolean | Force the user to restart the game when this setting is changed. | | `editable_context` | `"any"` \| `"main_menu"` \| `"in_save"` | If this setting can be changed only in the main menu, only in a save, or in both. When the current context does not match, the row is shown read-only with a note. The "enabled" setting and any `restart_required` settings are always treated as `"main_menu"`. Defaults to `"any"`. | | `show_as_percentage` | boolean | Append "%" to the value. | | `is_percentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | +| `on_change` | `fun(key, new_value)` | Called after the setting is changed in the in-game menu. Use it to apply the change to the loaded run. See below. | + +## Dynamic fields (functions) + +Most fields can also be dynamically resolved through a function call, which is evaluated when the menu +is opened and refreshed (after any other setting is changed). This lets a setting react to the live game +state or to other settings. The following may be a **function** returning the value instead of a +literal: `display_name`, `description`, `min`, `max`, `step`, `values`, `labels`, `order`, and +`disabled`. The function runs in your mod's environment, so it can read your `config`, and call functions +in your `mod` or the `game` namespace. + +Examples: + +```lua +biome_count = { + display_name = "Number of Regions", + min = 2, + max = function() return mod.MaxAllowedBiomeCount end, -- 8 or 12, resolved live +}, +meta_reward_fix_chance_cap = { + display_name = "Meta Reward Chance Cap", + min = 30, max = 90, + disabled = function() return not mod.config.meta_reward_fix end, -- greyed unless the fix toggle is on +}, +``` + +Use `disabled` (greys the row in place) for a condition that changes while the menu is open. `hidden` is +static only - it is evaluated only when the menu builds, and cannot be changed dynamically. + +## Action buttons + +A `configDesc` entry with an `action` function (and a key that has NO config value) renders as a button +that runs the callback when pressed, instead of editing a setting. It supports `display_name`, `description`, +`order`, `editable_context`, and `disabled` (grey the button live, e.g. until a value has changed). + +```lua +apply_scaling = { + action = function() mod.ApplyLateBiomeScaling() end, + display_name = "Apply Late Biome Scaling", + description = "Apply the scaling values above to the current run.", + editable_context = "in_save", -- greyed unless a save is loaded +}, +``` + +## Reacting to changes (`on_change`) + +Give a setting an `on_change` function to e.g. apply its new value to the live game when the player +changes it in the in-game options menu. It receives the setting's key and the new value: + +```lua +local configDesc = { + hermes_shrine_chance = { + display_name = "Hermes Shrine Chance", + min = 0, max = 100, + editable_context = "in_save", + on_change = function(key, new_value) + mod.ApplyHermesShrineChance(new_value) -- re-apply the value to the live run + end, + }, +} +``` + +The callback fires AFTER the new value is stored and the `.cfg` is saved, so reading the setting back +(directly or via your `config` proxy) returns the new value. It runs only for an edit made through the +in-game options menu, so: + +- It is **never called in the main menu** - there is no loaded run to apply to, and Lua game-data edits + are discarded when a save loads. +- It is **not called for other config writes** (e.g. from imgui or the config file). +- Re-writing the same value is a no-op and does not fire, so an `on_change` that writes another setting + cannot loop. +- Errors thrown in the callback are logged and do not propagate into the game. ## Localization tables diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index b4c2c85..7f24dac 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -6,33 +6,43 @@ --- Example: `{ en = "Difficulty", de = "Schwierigkeit" }` ---@alias mod_settings.localized_string string | table +--- Most fields can also be dynamically resolved through a function call, which is evaluated when the +--- menu is opened and refreshed (after any other setting is changed). +---@alias mod_settings.dynamic_number number | fun(): number +---@alias mod_settings.dynamic_boolean boolean | fun(): boolean +---@alias mod_settings.dynamic_string mod_settings.localized_string | fun(): mod_settings.localized_string + --- Describes how a config option appears in the in-game mod settings menu. Every field is optional. The --- widget type is inferred from the setting's config value (a boolean becomes a toggle; a number with `min` --- and `max` becomes a slider; a value with `values` becomes a cycler; anything else is a free-text field). ---@class (exact) mod_settings.setting_description --- Help text shown at the bottom of the options menu while the config rows is highlighted. Recommended to keep --- to about 35 characters so it leaves enough space for free-text input strings. ----@field description? mod_settings.localized_string +---@field description? mod_settings.dynamic_string --- Row label. Defaults to a prettified version of the config key (e.g. `myCool_Setting` -> "My Cool Setting"). ----@field display_name? mod_settings.localized_string +---@field display_name? mod_settings.dynamic_string --- Lower bound for a numeric setting. Combined with `max`, the setting renders as a slider. ----@field min? number +---@field min? mod_settings.dynamic_number --- Upper bound for a numeric setting. Combined with `min`, the setting renders as a slider. ----@field max? number +---@field max? mod_settings.dynamic_number --- Step between values for a slider and free-text number inputs. Defaults to 1. --- H2M will clamp the input automatically. ----@field step? number +---@field step? mod_settings.dynamic_number --- Enum options: the values actually stored in the .cfg file. --- Providing this makes the setting a cycler over these options. ----@field values? (string | number | boolean)[] +---@field values? (string | number | boolean)[] | fun(): (string | number | boolean)[] --- Display labels shown for each entry of `values` (same order, same number of entries). Each label may be a --- localization table. When omitted, the raw values are shown in the cycler. ----@field labels? mod_settings.localized_string[] +---@field labels? mod_settings.localized_string[] | fun(): mod_settings.localized_string[] --- Sort key for custom ordering config entries in the menu, lower first. --- When omitted, rows keep the order they are defined in the default config you provide. ----@field order? number ---- Hide this setting from the menu entirely. +---@field order? mod_settings.dynamic_number +--- Hide this setting from the menu entirely. Static only (evaluated when the menu builds) - for a +--- condition that changes while the menu is open, use `disabled`, which greys the setting out. ---@field hidden? boolean +--- Grey the setting out (shown read-only, cannot be changed) while this is true. Unlike `hidden`, a `disabled` +--- change updates live while the menu is open (e.g. grey a slider unless its parent toggle is enabled). +---@field disabled? mod_settings.dynamic_boolean --- Force a bounded number (one with `min` and `max`) to a free-text text field instead of a slider. ---@field freetext? boolean --- Mark that changing this setting requires a game restart. The menu forces the player @@ -46,8 +56,29 @@ ---@field show_as_percentage? boolean --- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). ---@field is_percentage? boolean +--- Called after this setting's value is changed through the in-game options menu, with the setting's key +--- and the new value. Use it to apply the change to the loaded run. It is not called in the main menu. +--- Re-writing the same value is a no-op and does not fire. Errors are logged, not propagated. +---@field on_change? fun(key: string, new_value: boolean|number|string) + +--- An action button in the menu that runs a callback instead of editing a config value. Declare it as a +--- `configDesc` entry (with a matching key that has NO config value) carrying an `action` function. +---@class (exact) mod_settings.action_description +--- The callback run when the button is activated. Runs in your mod's environment. +---@field action fun() +--- Button label. Defaults to a prettified version of the key. +---@field display_name? mod_settings.dynamic_string +--- Help text shown while the button is highlighted. +---@field description? mod_settings.dynamic_string +--- Sort key among the section's rows, lower first. +---@field order? mod_settings.dynamic_number +--- When the button is activated: only in the main menu, only in a save, or both. +---@field editable_context? "any" | "main_menu" | "in_save" +--- Grey the button out (non-interactive) while this is true. Updates live while the menu is open (e.g. +--- grey an "Apply" button until a value has actually changed). +---@field disabled? mod_settings.dynamic_boolean ---- Each entry in `configDesc` can either be simple key:description pair, or be a nested table using the allowed ---- parameters to enhance the way it is displayed in the in-game mod menu. The underlying .cfg file contents are +--- Each entry in `configDesc` can be a simple key:description string, a setting description table, an action +--- button, or a nested table of descriptions mirroring a config group. The underlying .cfg file contents are --- not changed by this format. ----@alias mod_settings.config_desc table +---@alias mod_settings.config_desc table diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index c31a17e..7f6e8be 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -11,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -26,30 +28,39 @@ using namespace al; namespace big::mod_settings { - // Author-declared per-setting metadata registry, populated from each mod's config.lua by - // rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Holds the display-name - // override, numeric bounds, enum options, ordering, and the restart-required flag that the - // settings menu reads to pick and drive a widget. Only settings whose config.lua description is - // a rich table are registered; the rest fall back to type-based rendering. + + // Author-declared per-setting metadata registry, populated from each mod's config.lua by rom.mod_settings.load. + // Keyed by guid + '\0' + section + '\0' + key. Holds the display-name override, numeric bounds, enum options, + // ordering, and the restart-required flag that the settings menu reads to pick and drive a widget. Only settings + // whose config.lua description is a rich table are registered. The rest fall back to type-based rendering. static std::mutex g_metadata_mutex; static std::map g_setting_metadata; - // Per-setting appearance order (rank of a key's definition in config.lua), populated for EVERY - // bound key (not just those with rich metadata). Keyed the same way as g_setting_metadata. The - // menu uses it to order rows that have no author-declared `order` in their config-file source - // order, because Lua pairs() and the alphabetical config map both lose the config.lua order. + // Per-setting appearance order (rank of a key's definition in config.lua), populated for EVERY bound key (not just + // those with rich metadata). Keyed the same way as g_setting_metadata. The menu uses it to order rows that have no + // author-declared `order` in their config-file source order, because Lua pairs() and the alphabetical config map + // both lose the config.lua order. static std::map g_appearance_order; - // Serialized config.lua default for every bound key (whether or not it has a rich metadata - // table), captured at load. The settings menu's Reset action restores a setting to this value. - // Keyed the same way as g_setting_metadata (guid + '\0' + section + '\0' + key). + // Serialized config.lua default for every bound key (whether or not it has a rich metadata table), captured at + // load. The settings menu's Reset action restores a setting to this value. Keyed the same way as g_setting_metadata + // (guid + '\0' + section + '\0' + key). static std::map g_setting_default; - // Guids of mods that called rom.mod_settings.opt_out(), i.e. asked not to be configured through - // the in-game menu. Guarded by g_metadata_mutex. Cleared and rebuilt on each Lua-state init (see - // bind_config_api) because opt_out re-runs with each mod's main.lua. + // Guids of mods that called rom.mod_settings.opt_out(), i.e. asked not to be configured through the in-game menu. + // Guarded by g_metadata_mutex. Cleared and rebuilt on each Lua-state init (see bind_config_api) because opt_out + // re-runs with each mod's main.lua. static std::set g_opted_out_mods; + // Action buttons declared in config.lua (configDesc entries with an `action` function, no config value). Keyed by + // guid, in config.lua source order. Plain data (the callable stays in the Lua-side description registry and is + // invoked by navigation). Cleared each Lua-state init in bind_config_api. + static std::map> g_actions; + + // The config section every mod's settings are bound under (matches SGG_Modding-Chalk, keeps the .cfg + // byte-compatible). Description tables in config.lua mirror the config table under this root. + static constexpr const char* root_section = "config"; + static std::string metadata_key(const std::string& guid, const std::string& section, const std::string& key) { std::string k; @@ -62,8 +73,8 @@ namespace big::mod_settings return k; } - // Drops a mod's metadata before it re-registers: config.lua may change between loads, and the - // Lua state is recreated on App::Reset (so load runs again for every mod). + // Drops a mod's metadata before it re-registers: config.lua may change between loads, and the Lua state is + // recreated on App::Reset (so load runs again for every mod). static void clear_metadata_for(const std::string& guid) { const std::string prefix = guid + '\0'; @@ -123,10 +134,10 @@ namespace big::mod_settings return g_opted_out_mods.count(guid) != 0; } - // Finds the byte offset of a key's definition (" =") in config.lua source, whole-word and - // not "==", or npos. The first match is the key's place in the returned `config` defaults table - // (defined before configDesc), which is the author's intended display order. Occurrences inside - // strings/prose don't match because they are not followed by a bare '='. + // Finds the byte offset of a key's definition (" =") in config.lua source, whole-word and not "==", or npos. + // The first match is the key's place in the returned `config` defaults table (defined before configDesc), which is + // the author's intended display order. Occurrences inside strings/prose don't match because they are not followed + // by a bare '='. static std::size_t find_key_definition(const std::string& src, const std::string& key) { auto is_ident = [](char c) @@ -157,12 +168,12 @@ namespace big::mod_settings return std::string::npos; } - static std::string serialize_option(const sol::object& v); // defined below + static std::string serialize_option(const sol::object& v); // defined below. - // Parses a user-facing string field that is either a plain scalar or a localization table (keyed by - // the game's language folder codes, e.g. { en = "...", ["zh-TW"] = "..." }). A scalar is stored under - // the empty key; a table contributes one entry per string-keyed string value. An empty or absent - // value yields an empty map (i.e. no override). + // Parses a user-facing string field that is either a plain scalar or a localization table (keyed by the game's + // language folder codes, e.g. { en = "...", ["zh-TW"] = "..." }). A scalar is stored under the empty key. A table + // contributes one entry per string-keyed string value. An empty or absent value yields an empty map (i.e. no + // override). static localized_text parse_localized(const sol::object& o) { localized_text out; @@ -186,9 +197,9 @@ namespace big::mod_settings return out; } - // Resolves a localized string to a single language-independent value for on-disk use (the .cfg - // comment), which is not re-written per language: English, then the unlocalized value, then any - // entry. The in-game menu resolves to the live game language separately at render time. + // Resolves a localized string to a single language-independent value for on-disk use (the .cfg comment), which is + // not re-written per language: English, then the unlocalized value, then any entry. The in-game menu resolves to + // the live game language separately at render time. static std::string localized_fallback(const localized_text& t) { if (t.empty()) @@ -206,9 +217,9 @@ namespace big::mod_settings return t.begin()->second; } - // Extracts the (possibly localized) description from a config.lua description value, which may be a - // plain string, or a rich table with a `description` field (or `[1]` shorthand) that is itself a - // plain string or a localization table. + // Extracts the (possibly localized) description from a config.lua description value, which may be a plain string, + // or a rich table with a `description` field (or `[1]` shorthand) that is itself a plain string or a localization + // table. static localized_text describe(const sol::object& desc) { if (desc.get_type() == sol::type::string) @@ -243,10 +254,32 @@ namespace big::mod_settings return flag.is() && flag.as(); } - // Serializes a Lua enum-option value (bool/number/string) into the exact string form a config - // entry serializes to, so the menu can match an option against the stored value. Numbers use - // the same locale-invariant std::format the toml converter uses, and every config number is - // stored as a double. + // Parses an `editable_context` field ("any"/"main_menu"/"in_save") returns `fallback` for anything else. Shared by + // setting metadata and action buttons. + static editable_context parse_editable_context(const sol::object& o, editable_context fallback) + { + if (o.get_type() == sol::type::string) + { + const std::string s = o.as(); + if (s == "main_menu") + { + return editable_context::main_menu; + } + if (s == "in_save") + { + return editable_context::in_save; + } + if (s == "any") + { + return editable_context::any; + } + } + return fallback; + } + + // Serializes a Lua enum-option value (bool/number/string) into the exact string form a config entry serializes to, + // so the menu can match an option against the stored value. Numbers use the same locale-invariant std::format the + // toml converter uses, and every config number is stored as a double. static std::string serialize_option(const sol::object& v) { switch (v.get_type()) @@ -273,18 +306,17 @@ namespace big::mod_settings } } - // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. - // Missing fields keep their defaults. The widget kind is not stored: the menu derives it from - // the config value's type plus the presence of `values` (enum), so authors never declare a - // `type`. Author-only inputs that cannot be inferred (name, bounds, enum options/labels, - // order, hidden, restart) are what this captures. + // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. Missing fields keep + // their defaults. The widget kind is not stored: the menu. Derives it from the config value's type plus the + // presence of `values` (enum), so authors never declare a `type`. Author-only inputs that cannot be inferred (name, + // bounds, enum options/labels, order, hidden, restart) are what this captures. static setting_metadata extract_metadata(const sol::table& desc) { setting_metadata m; m.description = describe(desc); - // Display-name override (`display_name`); empty -> the menu prettifies the key. May be a plain - // string or a localization table. + // Display-name override (`display_name`) empty -> the menu prettifies the key. May be a plain string or a + // localization table. sol::object display_name = desc["display_name"]; m.name = parse_localized(display_name); @@ -313,8 +345,8 @@ namespace big::mod_settings { return serialize_option(v); }); - // Enum option display labels (parallel to `values`); each may be a plain string or a - // localization table. + + // Enum option display labels (parallel to `values`). Each may be a plain string or a localization table. if (sol::object labels_obj = desc["labels"]; labels_obj.is()) { sol::table lt = labels_obj.as(); @@ -338,6 +370,12 @@ namespace big::mod_settings m.hidden = hidden_field.as(); } + sol::object disabled_field = desc["disabled"]; + if (disabled_field.is()) + { + m.disabled = disabled_field.as(); + } + sol::object freetext_field = desc["freetext"]; if (freetext_field.is()) { @@ -357,40 +395,210 @@ namespace big::mod_settings m.restart_required = description_requires_restart(desc); - // When the setting may be changed relative to a loaded save (`editable_context`). Accepts - // "any" (default), "main_menu", or "in_save"; anything else is ignored (stays `any`). The - // menu forces the master "enabled" toggle and restart_required settings to main_menu - // regardless, so authors need only annotate the in-between cases. - if (sol::object ctx_field = desc["editable_context"]; ctx_field.get_type() == sol::type::string) + // When the setting may be changed relative to a loaded save (`editable_context`). The menu forces the master + // "enabled" toggle and restart_required settings to main_menu regardless, so authors need only annotate the + // in-between cases. + m.context = parse_editable_context(desc["editable_context"], editable_context::any); + + // A field written as a Lua function is a dynamic field: It is skipped by the type-guarded reads above (a + // function is not a number/table/bool/string) and instead re-evaluated at render time by + // resolve_setting_metadata. Record that any such field is present so the menu knows to resolve. `hidden` is + // intentionally NOT dynamic: showing/hiding a row shifts the layout and the row set is only re-evaluated on a + // full rebuild, so a live-changing condition must use `disabled` instead. `editable_context` is a fixed design + // property of a setting, so it is static too. + for (const char* field : {"display_name", "description", "min", "max", "step", "values", "labels", "order", "disabled"}) + { + if (desc[field].get_type() == sol::type::function) + { + m.has_dynamic = true; + break; + } + } + + return m; + } + + // The Lua-side registry (rom.mod_settings._descs) mapping guid -> the mod's raw configDesc table, kept alive so + // dynamic (function) description fields and action callbacks can be evaluated at render time. Lua-owned and + // recreated with the rom.mod_settings table each Lua state, so it never dangles. Returns a nil object if the guid + // has no stored description. + static sol::object stored_descriptions(sol::state_view state, const std::string& guid) + { + sol::object ns = state[rom::g_lua_api_namespace]; + if (!ns.is()) + { + return sol::lua_nil; + } + sol::object ms = ns.as()["mod_settings"]; + if (!ms.is()) + { + return sol::lua_nil; + } + sol::object descs = ms.as()["_descs"]; + if (!descs.is()) + { + return sol::lua_nil; + } + return descs.as()[guid]; + } + + // Navigates a mod's stored configDesc to the description of (section, key). The configDesc mirrors the config table + // under the "config" root, so the section's remaining path (after "config") indexes nested description tables, then + // `key` selects the leaf/group description. Returns nil if any hop is missing or not a table. + static sol::object navigate_description(const sol::object& root, const std::string& section, const std::string& key) + { + if (!root.is()) + { + return sol::lua_nil; + } + sol::table node = root.as(); + + + // section is "config" or "config.a.b..." walk the part after the root. + std::string rel; + if (section.size() > std::strlen(root_section) && section.compare(0, std::strlen(root_section) + 1, std::string(root_section) + ".") == 0) + { + rel = section.substr(std::strlen(root_section) + 1); + } + std::size_t pos = 0; + while (pos < rel.size()) { - const std::string ctx = ctx_field.as(); - if (ctx == "main_menu") + const std::size_t dot = rel.find('.', pos); + const std::string part = rel.substr(pos, dot == std::string::npos ? std::string::npos : dot - pos); + sol::object child = node[part]; + if (!child.is()) { - m.context = editable_context::main_menu; + return sol::lua_nil; } - else if (ctx == "in_save") + node = child.as(); + if (dot == std::string::npos) + { + break; + } + pos = dot + 1; + } + return node[key]; + } + + // Calls a dynamic description field (a Lua function) protected, returning its result, or nil on error (logged). + // Non-function values are returned unchanged. + static sol::object evaluate_field(const sol::object& value, const std::string& guid, const char* field) + { + if (value.get_type() != sol::type::function) + { + return value; + } + sol::protected_function fn = value; + sol::protected_function_result rv = fn(); + if (!rv.valid()) + { + const sol::error err = rv; + LOG(WARNING) << "[mod_settings] dynamic '" << field << "' for " << guid << " failed: " << err.what(); + return sol::lua_nil; + } + return rv.get(); + } + + // Builds a shallow copy of a setting's description table with every dynamic (function) field replaced by its + // evaluated value, so the existing extract_metadata can read it as if the author had written static values. + // `on_change` and `action` callables are intentionally left as-is (they are invoked on their own events, not read + // as metadata). + static sol::table resolve_description(sol::state_view state, const sol::table& desc, const std::string& guid) + { + sol::table out = state.create_table(); + for (const auto& [k, v] : desc) + { + if (k.get_type() != sol::type::string) { - m.context = editable_context::in_save; + out[k] = v; + continue; } - else if (ctx == "any") + const std::string field = k.as(); + if (field == "on_change" || field == "action") { - m.context = editable_context::any; + out[k] = v; + continue; } + out[k] = evaluate_field(v, guid, field.c_str()); + } + return out; + } + + // Reads the static (non-function) action metadata common to collection and dynamic re-resolution. + static void read_action_fields(const sol::table& entry, action_info& a) + { + a.name = parse_localized(entry["display_name"]); + a.description = describe(entry); + if (sol::object o = entry["order"]; o.get_type() == sol::type::number) + { + a.has_order = true; + a.order = o.as(); + } + if (sol::object d = entry["disabled"]; d.is()) + { + a.disabled = d.as(); } + a.context = parse_editable_context(entry["editable_context"], editable_context::any); + } - return m; + // Walks a mod's configDesc (guided by the config defaults structure, like bind_defaults) collecting action buttons: + // description entries carrying an `action` function, which have no config value. Recurses into config groups so + // actions can live at any drilldown level. Static fields are captured now dynamic ones (has_dynamic) are + // re-resolved at render by get_actions. + static void collect_actions(const sol::table& config_tbl, const sol::object& desc_obj, const std::string& section, std::vector& out) + { + if (desc_obj.is()) + { + sol::table desc = desc_obj.as(); + for (const auto& [k, v] : desc) + { + if (k.get_type() != sol::type::string || !v.is()) + { + continue; + } + sol::table entry = v.as(); + if (entry["action"].get_type() != sol::type::function) + { + continue; + } + action_info a; + a.section = section; + a.key = k.as(); + read_action_fields(entry, a); + for (const char* field : {"display_name", "description", "order", "disabled"}) + { + if (entry[field].get_type() == sol::type::function) + { + a.has_dynamic = true; + break; + } + } + out.push_back(std::move(a)); + } + } + + // Recurse into child sections following the config structure (a table value is a group). + for (const auto& [k, v] : config_tbl) + { + if (k.get_type() != sol::type::string || !v.is()) + { + continue; + } + const sol::object child_desc = desc_obj.is() ? desc_obj.as()[k] : sol::object(sol::lua_nil); + collect_actions(v.as(), child_desc, section + "." + k.as(), out); + } } - // Finds the config entry for (section, key), or nullptr. m_entries is keyed by config_definition, - // so this is a direct map lookup. + // Finds the config entry for (section, key), or nullptr m_entries is keyed by config_definition, so this is a + // direct map lookup. static toml_v2::config_file::config_entry_base* find_entry(toml_v2::config_file* cf, const std::string& section, const std::string& key) { toml_v2::config_definition def(section, key); return cf->try_get_entry(def); } - // True if `section` is a bound section or the parent of one (some entry's section equals - // `section` or starts with `section + "."`). Used to expose nested config tables via the proxy. + // True if `section` is a bound section or the parent of one (some entry's section equals `section` or starts with + // `section + "."`). Used to expose nested config tables via the proxy. static bool has_section(toml_v2::config_file* cf, const std::string& section) { const std::string prefix = section + "."; @@ -423,8 +631,8 @@ namespace big::mod_settings return sol::lua_nil; } - // Writes a Lua value into a config entry, dispatching on the value's Lua type (matching the - // toml_v2 config_entry:set overloads: bool/number/string). + // Writes a Lua value into a config entry, dispatching on the value's Lua type (matching the toml_v2 + // config_entry:set overloads: bool/number/string). static void entry_set(toml_v2::config_file::config_entry_base* entry, const sol::object& value) { switch (value.get_type()) @@ -436,11 +644,44 @@ namespace big::mod_settings } } - // Live read/write view over a config_file section, returned to the mod as its `config` object. - // Reads/writes go straight through to the underlying config entries (so the in-game menu and the - // mod always see the same values); nested sections resolve to child proxies. It holds a raw - // config_file pointer (not a sol reference): the config_file is owned by the mod and both it and - // this proxy are recreated together per Lua state, so nothing dangles across an App::Reset. + // Attaches a Lua on_change callback (from a setting's config.lua description) to its config entry. toml_v2 already + // fires config_entry::m_setting_changed after a value changes and the file is saved. This routes that to Lua, + // passing the new value and the setting key. It fires only for an edit made through the in-game options menu + // (on_change_callbacks_enabled gates on the options screen being open in-game), so it is never called in the main + // menu - where there is no live run to apply to and Lua game-data edits are discarded when a save loads - nor from + // a mod's own config write outside the menu. A same-value write is a no-op and does not fire, so a callback that + // writes back cannot loop. It is stored on the entry, which is owned by the mod's config_file + // (module->m_data.m_config_files) and destroyed with the Lua state on App::Reset - so the captured sol reference + // shares the mod's lifecycle and never dangles (unlike a C++ static). Called protected: a Lua error is logged, + // never propagated. + static void attach_on_change(toml_v2::config_file::config_entry_base* entry, sol::protected_function callback) + { + if (!entry || !callback.valid()) + { + return; + } + entry->m_setting_changed = [callback = std::move(callback)](toml_v2::config_file::config_entry_base* changed) + { + if (!on_change_callbacks_enabled()) + { + return; + } + const sol::object value = entry_get(callback.lua_state(), changed); + sol::protected_function_result result = callback(changed->m_definition.m_key, value); + if (!result.valid()) + { + const sol::error err = result; + LOG(WARNING) << "[mod_settings] on_change callback failed for " << changed->m_definition.m_section << "." + << changed->m_definition.m_key << ": " << err.what(); + } + }; + } + + // Live read/write view over a config_file section, returned to the mod as its `config` object. Reads/writes go + // straight through to the underlying config entries (so the in-game menu and the mod always see the same values). + // Nested sections resolve to child proxies. It holds a raw config_file pointer (not a sol reference): the + // config_file is owned by the mod and both it and this proxy are recreated together per Lua state, so nothing + // dangles across an App::Reset. struct mod_config_proxy { toml_v2::config_file* cf = nullptr; @@ -465,12 +706,30 @@ namespace big::mod_settings if (auto* entry = find_entry(cf, section, key)) { entry_set(entry, value); + return; + } + + // Assigning a whole table to a nested section (e.g. config.group = { a = 1, b = 2 }, or a preset order to + // config.biome_pool.custom_order_data) sets each matching leaf in that child section, recursing for deeper + // tables. Only existing bound leaves are written. String keys with no entry are ignored, mirroring bind's + // string-key-only binding. + const std::string child = section + "." + key; + if (value.is() && has_section(cf, child)) + { + const mod_config_proxy child_proxy{cf, child}; + for (const auto& [k, v] : value.as()) + { + if (k.get_type() == sol::type::string) + { + child_proxy.new_index(k.as(), v); + } + } } } }; - // A setting's extracted metadata together with the section/key it belongs to, collected while - // walking config.lua and then folded into the registry. + // A setting's extracted metadata together with the section/key it belongs to, collected while walking config.lua + // and then folded into the registry. struct collected_metadata { std::string section; @@ -478,11 +737,11 @@ namespace big::mod_settings setting_metadata meta; }; - // Recursively binds a config.lua `defaults` table into `cf` under `section`, forwarding each - // leaf's description. Nested tables become sub-sections ("section.key"). Each flat leaf whose - // description is a rich table has its metadata extracted into `meta_out` (keyed by section+key). - // config_file::bind adopts a value already saved in the .cfg, preserving user edits, and binds - // under section "config" so the .cfg stays byte-compatible with what SGG_Modding-Chalk wrote. + // Recursively binds a config.lua `defaults` table into `cf` under `section`, forwarding each leaf's description. + // Nested tables become sub-sections ("section.key"). Each flat leaf whose description is a rich table has its + // metadata extracted into `meta_out` (keyed by section+key). config_file::bind adopts a value already saved in the + // .cfg, preserving user edits, and binds under section "config", so the .cfg stays byte-compatible with what + // SGG_Modding-Chalk wrote. static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector& meta_out, std::vector>& defaults_out) { sol::table desc_tbl; @@ -508,55 +767,65 @@ namespace big::mod_settings const sol::type vt = value_obj.get_type(); std::optional default_any; + toml_v2::config_file::config_entry_base* bound_entry = nullptr; switch (vt) { case sol::type::table: bind_defaults(cf, value_obj.as(), desc, section + "." + key, meta_out, defaults_out); break; case sol::type::boolean: - cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); + bound_entry = cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); default_any = std::any(value_obj.as()); break; case sol::type::number: - cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); + bound_entry = cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); default_any = std::any(value_obj.as()); break; case sol::type::string: - cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); + bound_entry = cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); default_any = std::any(value_obj.as()); break; default: continue; } - // Capture the config.lua default, serialized exactly as the entry serializes its own - // value, so the menu's Reset can round-trip it back through set_serialized_value. + // Capture the config.lua default, serialized exactly as the entry serializes its own value, so the menu's. + // Reset can round-trip it back through set_serialized_value. if (default_any) { defaults_out.emplace_back(section, key, toml_v2::toml_type_converter::convert_to_string(*default_any)); } - // A rich description table carries metadata. For a leaf it is the setting's metadata; for a - // nested group (a table value) it is group-level metadata (e.g. order/display_name/hidden) - // declared alongside the child descriptions. Registered under (section, key) either way. + // A rich description table carries metadata. For a leaf it is the setting's metadata. For a nested group (a + // table value). It is group-level metadata (e.g. order/display_name/hidden) declared alongside the child + // descriptions. Registered under (section, key) either way. if (desc.is()) { meta_out.push_back({section, key, extract_metadata(desc.as())}); + + // A leaf may also declare an on_change callback. Attach it to the bound entry so a menu edit (or the + // mod's own write) of this setting notifies the mod in Lua. + if (bound_entry) + { + sol::object on_change = desc.as()["on_change"]; + if (on_change.is()) + { + attach_on_change(bound_entry, on_change.as()); + } + } } } } - // Lua API: Function - // Table: mod_settings - // Name: load - // Param: config_lua: string: Path, relative to the mod's folder, of the config.lua that returns `config, configDesc`. - // Returns: table: A live read/write proxy over the mod's config; index it to read a setting and assign to write one. - // Loads a mod's config.lua and registers its settings under the Mods tab of the in-game Options menu, returning a live - // read/write proxy over the config. When using this, you do not need to depend on `Chalk`. + // Lua API: Function. Table: mod_settings. Name: load. Param: config_lua: string: Path, relative to the mod's + // folder, of the config.lua that returns `config, configDesc`. Returns: table: A live read/write proxy over the + // mod's config, index it to read a setting and assign to write one. Loads a mod's config.lua and registers its + // settings under the Mods tab of the in-game Options menu, returning a live read/write proxy over the config. When + // using this, you do not need to depend on `Chalk`. static sol::object load(sol::this_state ts, sol::this_environment this_env, const std::string& config_lua) { - // Uses the calling mod (this_environment) to derive its /.cfg path and create - // a native config_file owned by that mod, loads the mod's config.lua, binds its defaults and - // descriptions into that config_file, records any restart-required settings, and returns the proxy. + // Uses the calling mod (this_environment) to derive its /.cfg path and create a native + // config_file owned by that mod, loads the mod's config.lua, binds its defaults and descriptions into that + // config_file, records any restart-required settings, and returns the proxy. if (!this_env) { return sol::lua_nil; @@ -572,8 +841,8 @@ namespace big::mod_settings } const std::string guid = module->guid(); - // .cfg path = rom.path.combine(rom.paths.config(), guid .. ".cfg") - identical to the - // path Chalk used, so an existing .cfg is reused. + // .cfg path = rom.path.combine(rom.paths.config(), guid ".cfg") - identical to the path Chalk used, so an + // existing .cfg is reused. sol::table rom = env["rom"]; sol::function path_combine = rom["path"]["combine"]; sol::function config_folder = rom["paths"]["config"]; @@ -606,18 +875,36 @@ namespace big::mod_settings sol::object defaults = cfg_result[0]; sol::object descriptions = cfg_result[1]; - // Bind the defaults into the config_file (section root "config", matching Chalk) and collect - // each rich setting's metadata, then persist the file. + // Bind the defaults into the config_file (section root "config", matching. Chalk) and collect each rich + // setting's metadata, then persist the file. std::vector collected; - std::vector> collected_defaults; // (section, key, serialized) + std::vector> collected_defaults; // (section. if (defaults.is()) { bind_defaults(cf.get(), defaults.as(), descriptions, "config", collected, collected_defaults); } cf->save(); - // Read config.lua source to recover the author's key order (Lua pairs() and the alphabetical - // config map both lose it), then rank every bound key by where it is defined. + // Keep this mod's configDesc alive in Lua so the menu can evaluate dynamic (function) description fields and + // action callbacks at render time. Stored under rom.mod_settings._descs[guid], which is Lua-owned and recreated + // per state, so no sol reference is cached in a dangling C++ static. + if (sol::object ms_ns = rom["mod_settings"]; ms_ns.is()) + { + if (sol::object descs = ms_ns.as()["_descs"]; descs.is()) + { + descs.as()[guid] = descriptions; + } + } + + // Collect action buttons declared in configDesc (entries with an `action` function no config value). + std::vector actions; + if (defaults.is()) + { + collect_actions(defaults.as(), descriptions, root_section, actions); + } + + // Read config.lua source to recover the author's key order (Lua pairs() and the alphabetical config map both + // lose it), then rank every bound key by where it is defined. std::string source_text; { std::ifstream file(config_lua_path, std::ios::binary); @@ -628,7 +915,7 @@ namespace big::mod_settings source_text = ss.str(); } } - std::vector> by_offset; // (offset, section, key) + std::vector> by_offset; // (offset, section, key). for (const auto& [def, entry] : cf->m_entries) { const std::size_t off = source_text.empty() ? std::string::npos : find_key_definition(source_text, def.m_key); @@ -641,8 +928,18 @@ namespace big::mod_settings return std::get<0>(a) < std::get<0>(b); }); - // Register this mod's setting metadata + appearance order (replacing any from a previous load - // of the same mod). + // Order the collected actions by their position in the config.lua source so their menu order is deterministic + // and matches how the author wrote them (collect_actions walks in Lua pairs order, which is unspecified). + std::stable_sort(actions.begin(), + actions.end(), + [&](const action_info& a, const action_info& b) + { + const std::size_t oa = source_text.empty() ? std::string::npos : find_key_definition(source_text, a.key); + const std::size_t ob = source_text.empty() ? std::string::npos : find_key_definition(source_text, b.key); + return oa < ob; + }); + + // Register this mod's setting metadata + appearance order (replacing any from a previous load of the same mod). { std::scoped_lock lock(g_metadata_mutex); clear_metadata_for(guid); @@ -659,21 +956,106 @@ namespace big::mod_settings { g_appearance_order[metadata_key(guid, section, key)] = rank++; } + g_actions[guid] = std::move(actions); } return sol::make_object(ts, mod_config_proxy{cf.get(), "config"}); } - // Lua API: Function - // Table: mod_settings - // Name: opt_out - // Excludes the calling mod from the in-game mod settings menu: it stays listed but greyed out and - // cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod - // should not be edited in-game. Works with Chalk or rom.mod_settings.load. + std::optional resolve_setting_metadata(const std::string& guid, const std::string& section, const std::string& key) + { + if (!big::g_lua_manager) + { + return std::nullopt; + } + sol::state_view state = big::g_lua_manager->lua_state(); + const sol::object root = stored_descriptions(state, guid); + const sol::object desc = navigate_description(root, section, key); + if (!desc.is()) + { + return std::nullopt; + } + const sol::table resolved = resolve_description(state, desc.as(), guid); + setting_metadata m = extract_metadata(resolved); + m.has_dynamic = false; // already resolved to concrete values. + return m; + } + + std::vector get_actions(const std::string& guid, const std::string& section) + { + std::vector result; + { + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_actions.find(guid); + if (it == g_actions.end()) + { + return result; + } + for (const auto& a : it->second) + { + if (a.section == section) + { + result.push_back(a); + } + } + } + + // Re-evaluate any dynamic fields (name/description/order/disabled) against the current game state, mirroring + // resolve_setting_metadata for settings. + if (big::g_lua_manager) + { + sol::state_view state = big::g_lua_manager->lua_state(); + const sol::object root = stored_descriptions(state, guid); + for (auto& a : result) + { + if (!a.has_dynamic) + { + continue; + } + const sol::object desc = navigate_description(root, a.section, a.key); + if (desc.is()) + { + read_action_fields(resolve_description(state, desc.as(), guid), a); + } + } + } + return result; + } + + void invoke_action(const std::string& guid, const std::string& section, const std::string& key) + { + if (!big::g_lua_manager) + { + return; + } + sol::state_view state = big::g_lua_manager->lua_state(); + const sol::object root = stored_descriptions(state, guid); + const sol::object desc = navigate_description(root, section, key); + if (!desc.is()) + { + return; + } + const sol::object act = desc.as()["action"]; + if (act.get_type() != sol::type::function) + { + return; + } + sol::protected_function fn = act; + sol::protected_function_result rv = fn(); + if (!rv.valid()) + { + const sol::error err = rv; + LOG(WARNING) << "[mod_settings] action " << section << "." << key << " for " << guid << " failed: " << err.what(); + } + } + + // Lua API: Function. Table: mod_settings. Name: opt_out. Excludes the calling mod from the in-game mod settings + // menu: it stays listed but greyed out and cannot be opened, with a note pointing the player to the mod's own + // description. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. static void opt_out(sol::this_environment this_env) { - // Keyed by the calling mod's guid (which matches its config-file stem), so the menu can grey the - // matching row however the mod manages its config. + // Keyed by the calling mod's guid (which matches its config-file stem), so the menu can grey the matching row + // however the mod manages its config. if (!this_env) { return; @@ -689,25 +1071,30 @@ namespace big::mod_settings void bind_config_api(sol::state_view& state, sol::table& lua_ext) { - // A fresh Lua state re-runs every mod's main.lua, so drop all per-mod registries before those - // calls re-register them. load() also clears its own guid, but a mod uninstalled since the last - // state would never call load again, so its stale entries would otherwise linger forever; the - // opt-out set has no load() to hang a per-guid clear off either. Clearing everything here keeps - // all four registries bounded to the currently-loaded mods. + // A fresh Lua state re-runs every mod's main.lua, so drop all per-mod registries before those calls re-register + // them load() also clears its own guid, but a mod uninstalled since the last state would never call load again, + // so its stale entries would otherwise linger forever. The opt-out set has no load() to hang a per-guid clear + // off either. Clearing everything here keeps all four registries bounded to the currently-loaded mods. { std::scoped_lock lock(g_metadata_mutex); g_setting_metadata.clear(); g_appearance_order.clear(); g_setting_default.clear(); g_opted_out_mods.clear(); + g_actions.clear(); } - // Register the live-config proxy usertype once per state (mods never construct it; instances - // are returned from load). Its index/new_index read/write the underlying config entries. + // Register the live-config proxy usertype once per state (mods never construct it. Instances are returned from + // load). Its index/new_index read/write the underlying config entries. lua_ext.new_usertype("mod_config_proxy", sol::no_constructor, sol::meta_function::index, &mod_config_proxy::index, sol::meta_function::new_index, &mod_config_proxy::new_index); sol::table ns = lua_ext.create_named("mod_settings"); ns.set_function("load", &load); ns.set_function("opt_out", &opt_out); + + // A Lua-owned table holding each mod's raw configDesc (rom.mod_settings._descs[guid]), so the menu can evaluate + // dynamic (function) description fields and action callbacks at render time without caching sol references in. + // C++ statics (which would dangle across a Lua-state reset). + ns["_descs"] = state.create_table(); } } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index bc8ae34..524a6a2 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -32,64 +32,60 @@ namespace big::mod_settings using sgg::MiscSettingsScreen; using sgg::Vec2; - // Hades II's in-game options menu is the native C++ screen sgg::MiscSettingsScreen. - // Its category tabs include several non-user categories (Editor, Debug, ...) that are - // created but hidden; the "Editor" one is reused as the "Mods" tab. - // - // Option rows are native GUIComponentButtons built here. A freshly constructed - // component is invisible because it has no visual data; MenuScreen::ApplyDataToComponent - // applies the screen's SJSON template whose name matches the component's mName, which - // is what makes it render. So each row is named after an existing template - // ("CategoryOptionsButton"), has that template applied, is given its label, and is - // linked into mComponents (drawn) and mOptions (freed/unlinked on category switch). - - // GUIComponent::mName lives at this offset; it is an eastl::string used by - // ApplyDataToComponent to look up the matching template. + // Hades II's in-game options menu is the native C++ screen sgg::MiscSettingsScreen. Its category tabs include + // several non-user categories (Editor, Debug, ...) that are created but hidden. The "Editor" one is reused as the + // "Mods" tab. Option rows are native GUIComponentButtons built here. A freshly constructed component is invisible + // because it has no visual data. MenuScreen::ApplyDataToComponent applies the screen's SJSON template whose name + // matches the component's mName, which is what makes it render. So each row is named after an existing template + // ("CategoryOptionsButton"), has that template applied, is given its label, and is linked into mComponents (drawn) + // and mOptions (freed/unlinked on category switch). GUIComponent::mName lives at this offset. It is an + // eastl::string used by ApplyDataToComponent to look up the matching template. static constexpr std::size_t gui_component_name_offset = 0x4'88; - // Each GUIComponent embeds an sgg::ComponentData (mData) whose mDef (sgg::ComponentDataDef) - // drives its visuals/layout. Retuning mDef then re-running ComponentData::SetupComponent - // re-applies the template - this is how a plain button is converted into a key-rebind - // style text row. Offsets validated against the Ship Hades2.pdb. - static constexpr std::size_t component_data_offset = 0x88; // GUIComponent::mData (sgg::ComponentData) - static constexpr std::size_t component_def_offset = 0xA8; // mData(0x88) + ComponentData::mDef(0x20) + // Each GUIComponent embeds an sgg::ComponentData (mData) whose mDef (sgg::ComponentDataDef) drives its + // visuals/layout. Retuning mDef then re-running ComponentData::SetupComponent re-applies the template - this is how + // a plain button is converted into a key-rebind style text row. Offsets validated against the Ship Hades2.pdb. + static constexpr std::size_t component_data_offset = 0x88; // GUIComponent::mData (sgg::ComponentData). + static constexpr std::size_t component_def_offset = 0xA8; // mData(0x88) + ComponentData::mDef(0x20). + // Field offsets inside sgg::ComponentDataDef (relative to component_def_offset). - static constexpr std::size_t def_use_text_area = 0x05; // mUseTextArea (bool) - static constexpr std::size_t def_add_text_area = 0x06; // mAddTextArea (bool) - static constexpr std::size_t def_y = 0x20; // mY (float) row Y, read by UpdateScrollState + static constexpr std::size_t def_use_text_area = 0x05; // mUseTextArea (bool) + static constexpr std::size_t def_add_text_area = 0x06; // mAddTextArea (bool) + static constexpr std::size_t def_deselect_on_mouse_off = 0x13; // mDeselectOnMouseOff (bool) + + static constexpr std::size_t def_y = 0x20; // mY (float) row Y, read by UpdateScrollState static constexpr std::size_t def_offset_y = 0x2C; // mOffsetY (float) template vertical offset - static constexpr std::size_t def_scale = 0x34; // mScale (float) uniform component scale - static constexpr std::size_t def_text_offset_x = 0x50; // mTextOffsetX (float) - static constexpr std::size_t def_width = 0x74; // mWidth (float -> mCustomWidth) + static constexpr std::size_t def_scale = 0x34; // mScale (float) uniform component scale + static constexpr std::size_t def_text_offset_x = 0x50; // mTextOffsetX (float) + static constexpr std::size_t def_width = 0x74; // mWidth (float -> mCustomWidth) static constexpr std::size_t def_height = 0x78; // mHeight (float -> mCustomHeight) - static constexpr std::size_t def_graphic = 0x80; // mGraphic (HashGuid) - static constexpr std::size_t def_selected_graphic = 0x84; // mSelectedGraphic (HashGuid) - static constexpr std::size_t def_alternate_graphic = 0x88; // mAlternateGraphic (HashGuid) - static constexpr std::size_t def_add_color = 0x0D; // mAddColor (bool) - static constexpr std::size_t def_red = 0xEC; // mRed button tint (float) - static constexpr std::size_t def_green = 0xF0; // mGreen button tint (float) - static constexpr std::size_t def_blue = 0xF4; // mBlue button tint (float) - static constexpr std::size_t def_text_justification = 0xEA; // mTextJustification (sgg::Justification: LEFT=0) - static constexpr std::size_t def_text_red = 0x1'0C; // mTextRed (float) - static constexpr std::size_t def_text_green = 0x1'10; // mTextGreen (float) - static constexpr std::size_t def_text_blue = 0x1'14; // mTextBlue (float) - static constexpr std::size_t def_sel_text_red = 0x1'28; // mSelectedTextRed (float) - static constexpr std::size_t def_sel_text_green = 0x1'2C; // mSelectedTextGreen (float) - static constexpr std::size_t def_sel_text_blue = 0x1'30; // mSelectedTextBlue (float) + static constexpr std::size_t def_graphic = 0x80; // mGraphic (HashGuid) + static constexpr std::size_t def_selected_graphic = 0x84; // mSelectedGraphic (HashGuid) + static constexpr std::size_t def_alternate_graphic = 0x88; // mAlternateGraphic (HashGuid) + static constexpr std::size_t def_add_color = 0x0D; // mAddColor (bool) + static constexpr std::size_t def_red = 0xEC; // mRed button tint (float) + static constexpr std::size_t def_green = 0xF0; // mGreen button tint (float) + static constexpr std::size_t def_blue = 0xF4; // mBlue button tint (float) + static constexpr std::size_t def_text_justification = 0xEA; // mTextJustification (sgg::Justification: LEFT=0) + static constexpr std::size_t def_text_red = 0x1'0C; // mTextRed (float) + static constexpr std::size_t def_text_green = 0x1'10; // mTextGreen (float) + static constexpr std::size_t def_text_blue = 0x1'14; // mTextBlue (float) + static constexpr std::size_t def_sel_text_red = 0x1'28; // mSelectedTextRed (float) + static constexpr std::size_t def_sel_text_green = 0x1'2C; // mSelectedTextGreen (float) + static constexpr std::size_t def_sel_text_blue = 0x1'30; // mSelectedTextBlue (float) static constexpr std::size_t def_spacing = 0x1'5C; // mSpacing (float) row pitch, read by UpdateScrollState static constexpr std::size_t def_fade_speed = 0x2'1C; // mFadeSpeed (float) opacity ease rate (component +0x2C4) - // Opacity ease rate applied to every row so all row types fade at one uniform speed. The native - // OptionToggleButton / OptionNumBox templates use 10.0; CategoryOptionsButton (our text/value/ - // group rows) declares none, so we set it explicitly. GUIComponent::Update moves mFadeOpacity - // toward mFadeTarget by dt * mFadeSpeed each frame, so this drives the fade timing. + // Opacity ease rate applied to every row so all row types fade at one uniform speed. The native OptionToggleButton + // / OptionNumBox templates use 10.0. CategoryOptionsButton (our text/value/ group rows) declares none, so we set it + // explicitly. GUIComponent::Update moves mFadeOpacity toward mFadeTarget by dt * mFadeSpeed each frame, so this + // drives the fade timing. static constexpr float row_fade_speed = 10.0f; - // Native sgg::MessageDialog (the single-button message box the game shows in the MAIN MENU for - // save/file errors, ShellText SaveErrorPC/FileAccessErrorPC). Unlike the Lua screen system it - // does not need a loaded save, so it works when mods are toggled in the main menu. Offsets + - // RVAs DIA-validated against the current Ship Hades2.pdb. + // Native sgg::MessageDialog (the single-button message box the game shows in the MAIN MENU for save/file. Errors, + // ShellText SaveErrorPC/FileAccessErrorPC). Unlike the Lua screen system it does not need a loaded save, so it + // works when mods are toggled in the main menu. Offsets + RVAs. DIA-validated against the current Ship Hades2.pdb. static constexpr std::size_t message_dialog_size = 0x2'F0; // sizeof sgg::MessageDialog static constexpr std::size_t screen_manager_offset = 0x48; // sgg::GameScreen::mScreenManager static constexpr std::size_t screen_removed_offset = 0x21; // sgg::GameScreen::mRemoved (bool) @@ -99,82 +95,107 @@ namespace big::mod_settings static constexpr std::size_t dialog_confirm_button_offset = 0x1'A0; // sgg::MenuScreen::mConfirmButton static constexpr std::size_t dialog_message_offset = 0x2'B0; // sgg::MessageDialog::mMessageText - // The MessageDialog.sjson MessageText template renders at FontSize 26, which is larger than we - // want for the multi-line body. The rendered size is driven by GUIComponentTextBox::mFontHandle - // (@0x6A4); scaling its mFontSizeRatio (@+0x0C) / mEnglishFontSizeRatio (@+0x10) shrinks it. The - // def's mFontSize is ignored once the sjson template is loaded, so we scale the live handle. + // The MessageDialog.sjson MessageText template renders at FontSize 26, which is larger than we want for the + // multi-line body. The rendered size is driven by GUIComponentTextBox::mFontHandle (@0x6A4). Scaling its + // mFontSizeRatio (@+0x0C) / mEnglishFontSizeRatio (@+0x10) shrinks it. The def's mFontSize is ignored once the + // sjson template is loaded, so we scale the live handle. static constexpr std::size_t textbox_font_handle_offset = 0x6'A4; // GUIComponentTextBox::mFontHandle static constexpr std::size_t font_handle_size_ratio_offset = 0x0C; // sgg::FontHandle::mFontSizeRatio static constexpr std::size_t font_handle_eng_size_ratio_offset = 0x10; // sgg::FontHandle::mEnglishFontSizeRatio static constexpr float restart_message_font_scale = 0.75f; // ~26 -> ~19.5 - // Module-relative RVAs (current Ship build) for the overloaded functions that cannot be picked - // by name from the PDB symbol map. Resolved at runtime relative to the button-ctor anchor: - // anchor_runtime - anchor_rva + target_rva. AddScreen has three overloads; the 4-arg one - // inserts at the END of the screen list (drawn on top), unlike the 2-arg one which front-inserts - // (drawn under the full-screen options menu = invisible). - static constexpr std::uintptr_t anchor_rva = 0x11'5C'70; // sgg::GUIComponentButton::GUIComponentButton - static constexpr std::uintptr_t message_dialog_ctor_rva = 0x16'EE'60; // sgg::MessageDialog::MessageDialog(this,sm,eastl::string*) - static constexpr std::uintptr_t add_screen_rva = 0x14'7D'D0; // sgg::ScreenManager::AddScreen(this,screen,bool,eastl::string*) - // tf_new_internal: the game's own factory that - // allocates a GUIComponentNumBox, sets its vtable and builds its 5 sub-components (box graphic, - // label, value text, left/right arrows). Template instantiation, so resolved by RVA off the anchor. + // Module-relative RVAs (current Ship build) for the overloaded functions that cannot be picked by name from the PDB + // symbol map. Resolved at runtime relative to the button-ctor anchor: anchor_runtime - anchor_rva + target_rva. + // AddScreen has three overloads. The 4-arg one inserts at the END of the screen list (drawn on top), unlike the + // 2-arg one which front-inserts (drawn under the full-screen options menu = invisible). + static constexpr std::uintptr_t anchor_rva = 0x11'5C'70; // GUIComponentButton::GUIComponentButton + static constexpr std::uintptr_t message_dialog_ctor_rva = 0x16'EE'60; // sgg::MessageDialog::MessageDialog + static constexpr std::uintptr_t add_screen_rva = 0x14'7D'D0; // sgg::ScreenManager::AddScreen + + // tf_new_internal: the game's own factory that allocates a + // GUIComponentNumBox, sets its vtable and builds its 5 sub-components (box graphic, label, value text, left/right + // arrows). Template instantiation, so resolved by RVA off the anchor. static constexpr std::uintptr_t numbox_factory_rva = 0x17'A5'30; - // eastl::vector::push_back, used only as a fallback when the named PDB symbol is - // missing (it is sometimes emitted inline). Resolved off the same button-ctor anchor. + // eastl::vector::push_back, used only as a fallback when the named PDB symbol is missing (it is + // sometimes emitted inline). Resolved off the same button-ctor anchor. static constexpr std::uintptr_t push_back_rva = 0x14'1E'D0; - // sgg::MenuScreen::TeleportCursorTo(this, GUIComponent*) - the 2-arg overload that drops the - // controller/keyboard free-form cursor onto a component - and sgg::ConfigOptions::UseMouse, the - // global bool that is false in controller/keyboard mode. Both addressed by RVA off the anchor. + // sgg::MenuScreen::TeleportCursorTo(this, GUIComponent*) - the 2-arg overload that drops the controller/keyboard + // free-form cursor onto a component - and sgg::ConfigOptions::UseMouse, the global bool that is false in + // controller/keyboard mode. Both addressed by RVA off the anchor. static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; static constexpr std::uintptr_t config_use_mouse_rva = 0x83'69'15; - // sgg::ConfigOptions::Language: eastl string holding the current display-language code (e.g. "en", - // "zh-TW"). Used to pick text/blank characters the current locale's font can render. + + // sgg::ConfigOptions::Language: eastl string holding the current display-language code (e.g. "en", "zh-TW"). Used + // to pick text/blank characters the current locale's font can render. static constexpr std::uintptr_t config_language_rva = 0x83'69'20; - // &sgg::Controls::Cancel (the remappable Back/Cancel action that folds together controller B and - // keyboard Esc) and &sgg::Controls::Select (controller A + Enter); their first int is the id - // indexing InputHandler's control-state array. + // &sgg::Controls::Cancel (the remappable. Back/CancelBack/Cancel action that folds together controller B and + // keyboard Esc) and &sgg::Controls::Select (controller A + Enter). Their first int is the id indexing + // InputHandler's control-state array. static constexpr std::uintptr_t config_cancel_rva = 0x55'12'20; static constexpr std::uintptr_t config_select_rva = 0x55'1D'80; - // sgg::GUIComponentNumBox field offsets (DIA-validated on the current Ship build). sizeof 0x5D0; - // derives directly from GUIComponent (not GUIComponentButton). - static constexpr std::size_t numbox_value_offset = 0x5'40; // mNumberValue (float) - static constexpr std::size_t numbox_step_offset = 0x5'44; // mNumberStepValue (float) - static constexpr std::size_t numbox_min_offset = 0x5'48; // mNumberMin (float) - static constexpr std::size_t numbox_max_offset = 0x5'4C; // mNumberMax (float) - static constexpr std::size_t numbox_is_integer_offset = 0x5'50; // mIsInteger (bool: discrete + integer display) - static constexpr std::size_t numbox_disable_input_offset = 0x5'63; // mDisableInput (bool: HandleInput early-out) - static constexpr std::size_t numbox_value_text_offset = 0x5'B0; // mValueTextBox (GUIComponentTextBox*) - static constexpr std::size_t numbox_left_arrow_offset = 0x5'98; // mLeftArrow (GUIComponentAnimation*) - static constexpr std::size_t numbox_right_arrow_offset = 0x5'A0; // mRightArrow (GUIComponentAnimation*) - static constexpr std::size_t numbox_label_text_offset = 0x5'A8; // mTextBox (GUIComponentTextBox*, the label) - static constexpr std::size_t numbox_sizeof = 0x5'D0; - - // sgg::GUIComponentSlider (the horizontal drag bar used by the audio-volume options). DIA-validated - // on the current Ship build; sizeof 0x5B0, derives directly from GUIComponent. It is a pure 0..1 - // fraction control (no min/max/step fields) - the value is mFraction and the fill graphic redraws - // from it. The game has no factory for it (DoShowCategory hand-rolls the allocation + the four - // sub-components), so make_slider_row replicates that construction. - static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'48; // ??_7GUIComponentSlider@sgg@@6B@ (off the anchor) + // sgg::GUIComponentNumBox field offsets (DIA-validated on the current Ship build) sizeof 0x5D0. Derives directly + // from GUIComponent (not GUIComponentButton). + static constexpr std::size_t numbox_value_offset = 0x5'40; // mNumberValue (float) + static constexpr std::size_t numbox_step_offset = 0x5'44; // mNumberStepValue (float) + static constexpr std::size_t numbox_min_offset = 0x5'48; // mNumberMin (float) + static constexpr std::size_t numbox_max_offset = 0x5'4C; // mNumberMax (float) + static constexpr std::size_t numbox_is_integer_offset = 0x5'50; // mIsInteger (bool: discrete + integer display) + static constexpr std::size_t numbox_disable_input_offset = 0x5'63; // mDisableInput (bool: HandleInput early-out) + static constexpr std::size_t numbox_value_text_offset = 0x5'B0; // mValueTextBox (GUIComponentTextBox*) + static constexpr std::size_t numbox_left_arrow_offset = 0x5'98; // mLeftArrow (GUIComponentAnimation*) + static constexpr std::size_t numbox_right_arrow_offset = 0x5'A0; // mRightArrow (GUIComponentAnimation*) + static constexpr std::size_t numbox_label_text_offset = 0x5'A8; // mTextBox (GUIComponentTextBox*, the label) + static constexpr std::size_t numbox_sizeof = 0x5'D0; + + // sgg::GUIComponentButton box-graphic scaling. The box ("Button_Secondary") is a single-frame animation reached + // via mAnim. GUIComponentButton::Draw pushes only a uniform scale into it, so a non-uniform (wider) box needs the + // anim's own def mScaleX plus mScaleModifierOnlyX, which the anim draw path honours. Offsets DIA-validated on the + // current Ship build (button-box-width RE). + static constexpr std::size_t button_anim_offset = 0x5'70; // GUIComponentButton::mAnim (GUIComponentAnimation*) + // GUIComponentButton::GetArea reads GUIComponentButton::mLabel location, not the button's. + static constexpr std::size_t button_label_offset = 0x5'80; // GUIComponentButton::mLabel (GUIComponentTextBox*) + + static constexpr std::size_t anim_scale_modifier_only_x_offset = 0x5'42; // mScaleModifierOnlyX (bool) + // component_def_scale_* offsets are from the component base. + static constexpr std::size_t component_def_scale_x_offset = 0x1'14; // mData.mDef.mScaleX (float) + + static constexpr std::size_t component_def_scale_y_offset = 0x1'18; // mData.mDef.mScaleY (float) + + // The Button_Secondary sprite's native atlas width in px. The box draws at native * mScale * mScaleX. + static constexpr float button_graphic_native_width = 350.0f; + + // Approximate label capacity (in measure_width glyph units) of the box at its native width. Padding is kept around + // the label. A label wider than this stretches. The box just enough to fit, so short buttons keep the clean native + // box and only long ones widen (mild end-cap distortion). + static constexpr float button_label_capacity = 15.0f; + static constexpr float button_label_padding = 2.0f; + + // sgg::GUIComponentSlider (the horizontal drag bar used by the audio-volume options). DIA-validated on the current + // Ship build, sizeof 0x5B0, derives directly from GUIComponent. It is a pure 0..1 fraction control (no min/max/step + // fields) - the value is mFraction and the fill graphic redraws from it. The game has no factory for it + // (DoShowCategory hand-rolls the allocation + the four sub-components), so make_slider_row replicates that + // construction. + static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'48; // ??_7GUIComponentSlider@sgg@@6B@ (off the anchor). static constexpr std::size_t slider_sizeof = 0x5'B0; static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking / mFill) static constexpr std::size_t textbox_sizeof = 0x6'C0; // sgg::GUIComponentTextBox (mLabel / mValueTextBox) static constexpr std::size_t menu_screen_container_offset = 0x50; // owner + 0x50 = the IGUIComponentContainer base - static constexpr std::size_t slider_parent_offset = 0x3'90; // GUIComponent::mParentContainer (SetParent writes here) - static constexpr std::size_t slider_owner_offset = 0x5'40; // mOwner (MenuScreen*) + static constexpr std::size_t slider_parent_offset = 0x3'90; // GUIComponent::mParentContainer, SetParent writes + + static constexpr std::size_t slider_owner_offset = 0x5'40; // mOwner (MenuScreen*) static constexpr std::size_t slider_on_changed_offset = 0x5'58; // mOnValueChanged (vector begin/end/cap, 3 qwords) - static constexpr std::size_t slider_backing_offset = 0x5'70; // mBacking (GUIComponentImage*, bar background) - static constexpr std::size_t slider_fill_offset = 0x5'78; // mFill (GUIComponentImage*, progress fill) - static constexpr std::size_t slider_label_offset = 0x5'90; // mLabel (GUIComponentTextBox*, left label) - static constexpr std::size_t slider_value_text_offset = 0x5'98; // mValueTextBox (GUIComponentTextBox*, right value) - static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) - - // Scalar deleting destructor slot in the GUIComponent vtable. Called with flags=0 it destructs - // and frees any owned sub-components without the final operator delete, so we then _aligned_free. + static constexpr std::size_t slider_backing_offset = 0x5'70; // mBacking (GUIComponentImage*, bar background) + static constexpr std::size_t slider_fill_offset = 0x5'78; // mFill (GUIComponentImage*, progress fill) + static constexpr std::size_t slider_label_offset = 0x5'90; // mLabel (GUIComponentTextBox*, left label) + static constexpr std::size_t slider_value_text_offset = 0x5'98; // mValueTextBox (GUIComponentTextBox*, right value) + static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) + + // Scalar deleting destructor slot in the GUIComponent vtable. Called with flags=0 it destructs and frees any owned + // sub-components without the final operator delete, so we then. _aligned_free. static constexpr std::size_t vtable_deleting_dtor_offset = 0x1'88; using ctor_fn = void* (*)(void* button, void* owner_screen); @@ -196,8 +217,9 @@ namespace big::mod_settings using numbox_factory_fn = void* (*)(const char* file, int line, const char* tag, void** screen); using numbox_set_range_fn = void (*)(void* num_box, float min, float max); using numbox_set_value_fn = void (*)(void* num_box, float value, bool notify); - // GUIComponent-derived constructors take the initial location as a Vec2 passed by value (packed - // into a single 64-bit register); 0 is the origin. Used to hand-build a slider and its sub-components. + + // GUIComponent-derived constructors take the initial location as a Vec2 passed by value (packed into a single. + // 64-bit register). 0 is the origin. Used to hand-build a slider and its sub-components. using gui_component_ctor_fn = void (*)(void* self, std::uint64_t location_packed); using slider_defaults_fn = void (*)(void* slider); using slider_set_fraction_fn = void (*)(void* slider, float fraction, bool notify); @@ -213,10 +235,9 @@ namespace big::mod_settings using hash_lookup_fn = HashGuid* (*)(HashGuid * out, const char* str, std::size_t len); - // sgg::ProfileManager::SaveProfile(eastl::string* profileName, bool showSpinner, bool async): - // serializes the active profile (language, audio volumes, resolution/window/VSync/graphics, and all - // gameplay/interface/accessibility toggles) to disk. Called synchronous (async=false) to guarantee - // the write completes before we force a restart. + // sgg::ProfileManager::SaveProfile(eastl::string* profileName, bool showSpinner, bool async): serializes the active + // profile (language, audio volumes, resolution/window/VSync/graphics, and all gameplay/interface/accessibility + // toggles) to disk. Called synchronous (async=false) to guarantee the write completes before we force a restart. using save_profile_fn = char (*)(void* profile_name, bool show_spinner, bool async); static ctor_fn g_button_ctor = nullptr; @@ -244,20 +265,21 @@ namespace big::mod_settings static gui_component_ctor_fn g_textbox_ctor = nullptr; static slider_defaults_fn g_slider_defaults = nullptr; static slider_set_fraction_fn g_slider_set_fraction = nullptr; - static std::uintptr_t g_slider_vtable = 0; // runtime slider vftable address (anchor_base + slider_vtable_rva) - static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) - static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) - static const char* g_config_language = nullptr; // sgg::ConfigOptions::Language (eastl SSO string, code chars at offset 0) + static std::uintptr_t g_slider_vtable = 0; // runtime + static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) + static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) + static const char* g_config_language = nullptr; // sgg::ConfigOptions::Language + static component_focused_fn g_component_focused = nullptr; // focuses a row so it receives stick input + green static input_get_state_fn g_input_get_state = nullptr; // reads a remappable control's per-frame state static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A / Enter) static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) - static void* g_active_profile = nullptr; // &sgg::ProfileManager::ACTIVE_PROFILE (eastl::string, the profile name arg) + static void* g_active_profile = nullptr; // &sgg::ProfileManager::ACTIVE_PROFILE - // Set true by register_hooks only once every engine symbol, RVA and offset the Mods tab needs has - // resolved for the running game build. While false no hooks are installed and the tab is absent; - // it also gates process-global side effects (the wndproc callback) as a safety net. + // Set true by register_hooks only once every engine symbol, RVA and offset the Mods tab needs has resolved for the + // running game build. While false no hooks are installed and the tab is absent. It also gates process-global side + // effects (the wndproc callback) as a safety net. static bool g_feature_enabled = false; // sgg::KeyboardButtonId values used for edit confirm/cancel (validated in the PDB). @@ -265,36 +287,42 @@ namespace big::mod_settings static constexpr int key_kp_enter = 113; static constexpr int key_return = 127; - // Hash of the game's "Blank" (empty) graphic, resolved once, used to hide a row's - // button background so it renders as a plain text label. + // Hash of the game's "Blank" (empty) graphic, resolved once, used to hide a row's button background so it renders + // as a plain text label. static std::uint32_t g_blank_graphic = 0; - // Panel layout, in native 1080p menu coordinates. The engine's UpdateScrollState pass - // positions each on-page row at Y = (index - pageStart) * row_pitch + row_base_y + - // ScreenCenterOffsetY, and X = the row's own location. Rows mirror the key-rebind - // ControlButton layout: the component is anchored to the right pane and its text is + // Panel layout, in native 1080p menu coordinates. The engine's UpdateScrollState pass positions each on-page row at + // Y = (index - pageStart) * row_pitch + row_base_y + ScreenCenterOffsetY, and X = the row's own location. Rows + // mirror the key-rebind ControlButton layout: the component is anchored to the right pane and its text is // left-justified via a negative text offset, matching the native option-name column. static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column - static constexpr float value_text_offset_x = 15.0f; // right-justify the value; right edge aligns with the toggle's + static constexpr float value_text_offset_x = 15.0f; // right-justify the value, right edge aligns with the toggle's static constexpr float numbox_location_x = 1365.0f; // native OptionNumBox X (box + arrows clear the scrollbar) - static constexpr float slider_location_x = 1330.0f; // native OptionSlider X (bar + value clear the scrollbar; the template's label offset puts the name in the option-name column) + static constexpr float slider_location_x = 1330.0f; // native OptionSlider X (bar + value clear the scrollbar + // template's label offset puts the name in the option-name column). static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) static constexpr float row_base_y = 300.0f; // first row's Y - matches the vanilla option templates static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) static constexpr std::uint32_t rows_per_page = 10; // vanilla ItemsPerPage = 10 - // Config sections. Both rom.mod_settings.load and Chalk bind a mod's settings under the root - // "config" section; nested groups are dot-separated child sections (e.g. "config.biome_pool"). + // Action-button rows use the taller Button_Secondary box, which overflows the uniform row pitch. After the native + // layout, sync_button_spacing nudges each button down by this lead and shifts the rows below it by lead+trail, so + // buttons get vertical breathing room without overlapping. + static constexpr float button_extra_lead = 14.0f; + static constexpr float button_extra_trail = 14.0f; + + // Config sections. Both rom.mod_settings.load and Chalk bind a mod's settings under the root "config" section. + // Nested groups are dot-separated child sections (e.g. "config.biome_pool"). static const std::string root_section = "config"; - // Chalk writes a placeholder entry with this key per section so empty groups persist; skip it. + + // Chalk writes a placeholder entry with this key per section so empty groups persist. Skip it. static constexpr const char* section_empty_key = "..."; - // Approximate visual width budget for the right-column value (freetext + its edit caret), in - // "width units" where a typical medium glyph is 1.0. The menu font is variable-width, so a raw - // character count looks inconsistent (a run of 'W' is far wider than a run of 'i'); budgeting by - // summed glyph weight keeps the shown value a consistent WIDTH so it does not run left into the - // key label. ~30 units is roughly 30 average glyphs wide. + // Approximate visual width budget for the right-column value (freetext + its edit caret), in "width units" where a + // typical medium glyph is 1.0 The menu font is variable-width, so a raw character count looks inconsistent (a run + // of 'W' is far wider than a run of 'i') budgeting by summed glyph weight keeps the shown value a consistent WIDTH + // so it does not run left into the key label. ~30 units is roughly 30 average glyphs wide. static constexpr float value_display_max_width = 30.0f; // Edit-cursor blink half-period (ms): the "|" shows for this long, then hides. @@ -316,40 +344,39 @@ namespace big::mod_settings std::string stem; // owning mod's config-file stem std::string setting_key; // config entry key (setting rows only) - // The bound config entry (setting rows only); valid for the config file's lifetime, - // which spans the whole menu session. + // The bound config entry (setting rows only) valid for the config file's lifetime, which spans the whole menu + // session. toml_v2::config_file::config_entry_base* entry = nullptr; bool disabled = false; // greyed & non-interactable (mod disabled) bool is_enabled_toggle = false; // the mod's master "enabled" toggle - // Author-provided description shown at the bottom of the screen while this row is - // highlighted (setting rows only; empty for navigation rows). + // Author-provided description shown at the bottom of the screen while this row is highlighted (setting rows + // only empty for navigation rows). std::string description; - // Right-column value display for a non-bool setting row (paired with `component`, the - // left-column key). Not in mOptions; positioned to follow `component` each frame. + // Right-column value display for a non-bool setting row (paired with `component`, the left-column key). Not in + // mOptions positioned to follow `component` each frame. GUIComponent* value_component = nullptr; - // Bounded number setting (metadata has both min and max). Rendered as a native slider (drag - // bar) spanning [stepper_min, stepper_max] and snapped to stepper_step; is_slider marks that. - // If the slider cannot be built it falls back to a number-box stepper (is_stepper) that steps - // by stepper_step. A number without bounds uses the freetext editor instead. + // Bounded number setting (metadata has both min and max). Rendered as a native slider (drag bar) spanning + // [stepper_min, stepper_max] and snapped to stepper_step. is_slider marks that. If the slider cannot be built + // it falls back to a number-box stepper (is_stepper) that steps by stepper_step. A number without bounds uses + // the freetext editor instead. bool is_slider = false; bool is_stepper = false; double stepper_min = 0.0; double stepper_max = 0.0; double stepper_step = 1.0; - // Number-display options (slider value text): is_percentage shows a 0..1 value as 0..100 and - // appends "%"; show_as_percentage only appends "%". + // Number-display options (slider value text): is_percentage shows a 0..1 value as 0..100 and appends "%" + // show_as_percentage only appends "%". bool show_as_percentage = false; bool is_percentage = false; - // Enum cycler (metadata has `values`). Rendered as a native number box over the index - // 0..labels-1 whose value text is overridden to the label (like the game's own enum - // options). `enum_values` are the serialized config values, `enum_labels` the parallel - // display strings; both indexed by the box's current integer value. + // Enum cycler (metadata has `values`). Rendered as a native number box over the index 0..labels-1 whose value + // text is overridden to the label (like the game's own enum options) `enum_values` are the serialized config + // values, `enum_labels` the parallel display strings both indexed by the box's current integer value. bool is_enum = false; std::vector enum_values; std::vector enum_labels; @@ -360,36 +387,35 @@ namespace big::mod_settings static std::vector g_rows; - // Set when a restart-required setting is changed this menu session (e.g. toggling the - // "enabled" switch of an sjson-backed mod). On options-menu close we warn + close the game. + // Set when a restart-required setting is changed this menu session (e.g. toggling the "enabled" switch of an + // sjson-backed mod). On options-menu close we warn + close the game. static bool g_restart_required = false; - // The restart-causing changes this session, keyed by "\0
\0" so re-editing - // the same setting overwrites its line rather than adding a duplicate. Values are the - // human-readable lines listed in the restart popup, e.g. "MyMod: Enabled (on)". + // The restart-causing changes this session, keyed by "\0
\0" so re-editing the same setting + // overwrites its line rather than adding a duplicate. Values are the human-readable lines listed in the restart + // popup, e.g. "MyMod: Enabled (on)". static std::map g_restart_changes; - // Baseline serialized value (as of this menu session's open) for each restart-required setting - // that was touched, keyed identically to g_restart_changes. Used to drop a setting from the - // restart list when it is changed back to its baseline (no net change -> no restart needed). + // Baseline serialized value (as of this menu session's open) for each restart-required setting that was touched, + // keyed identically to g_restart_changes. Used to drop a setting from the restart list when it is changed back to + // its baseline (no net change -> no restart needed). static std::map g_restart_baselines; - // The native restart message box's (only) button; clicking it closes the game (restart). + // The native restart message box's (only) button clicking it closes the game (restart). static GUIComponent* g_restart_confirm_button = nullptr; - // The restart message box itself (owner of g_restart_confirm_button). Used only to re-validate that - // a clicked button really is the live restart dialog's button before terminating: matching the - // button pointer alone would be fooled if that dialog were freed and another button reused its - // address. A genuine restart button's owner is this dialog; any rebuilt row's owner is the options - // screen, so it will not match. + // The restart message box itself (owner of g_restart_confirm_button). Used only to re-validate that a clicked + // button really is the live restart dialog's button before terminating: matching the button pointer alone would be + // fooled if that dialog were freed and another button reused its address. A genuine restart button's owner is this + // dialog any rebuilt row's owner is the options screen, so it will not match. static void* g_restart_dialog = nullptr; // True once the restart prompt has been shown this menu session (so closing again proceeds). static bool g_restart_prompt_shown = false; - // Which view the Mods panel is currently showing, plus a deferred navigation request - // that a click sets and the Update hook applies at a safe point (outside input/click - // iteration, where mutating the component vectors is safe). + // Which view the Mods panel is currently showing, plus a deferred navigation request that a click sets and the + // Update hook applies at a safe point (outside input/click iteration, where mutating the component vectors is + // safe). enum class View { mod_list, @@ -403,14 +429,22 @@ namespace big::mod_settings static View g_pending_view = View::mod_list; static std::string g_pending_stem; static std::string g_pending_section; - static bool g_nav_reset_to_top = false; // Reset action: force a top (non-instant) rebuild next apply_nav - - // Navigation restore stack: one entry per drill-in level (the mod list into a mod, or a section into - // a child group). Each records the parent view's scroll offset and the identity of the row drilled - // through, so backing out restores that scroll and re-selects that row instead of snapping to the - // top. focus_stem identifies a mod row (returning to the mod list); focus_section identifies a group - // row by its target section (returning to a parent section). g_pending_restore holds the entry - // popped by the current back-navigation for build_panel to consume. + static bool g_nav_reset_to_top = false; // Reset action: force a top (non-instant) rebuild next apply_nav. + + // Seconds of input quiet after a numeric setting (slider / number-box) changes before the view is rebuilt to + // re-evaluate its dynamic (Lua-function) rows - e.g. an apply button's dynamic `disabled`. + static constexpr float dynamic_refresh_settle_seconds = 0.15f; + + // Time left on that debounce (0 = nothing pending). A slider fires its change hook every frame while dragged and a + // rebuild frees the dragged row, so we wait for a short quiet gap and rebuild once the drag settles. Re-armed on + // every change ticked down in the Update hook. + static float g_dynamic_refresh_settle = 0.0f; + + // Navigation restore stack: one entry per drill-in level (the mod list into a mod, or a section into a child group) + // Each records the parent view's scroll offset and the identity of the row drilled through, so backing out restores + // that scroll and re-selects that row instead of snapping to the top focus_stem identifies a mod row (returning to + // the mod list) focus_section identifies a group row by its target section (returning to a parent section) + // g_pending_restore holds the entry popped by the current back-navigation for build_panel to consume. struct NavRestore { std::uint32_t scroll_index = 0; @@ -422,23 +456,22 @@ namespace big::mod_settings static NavRestore g_pending_restore; static bool g_has_pending_restore = false; - // Freetext edit state (number/string settings). A click enters edit mode; typed input - // is captured in the window procedure and applied on the game thread in the Update hook. + // Freetext edit state (number/string settings). A click enters edit mode typed input is captured in the window + // procedure and applied on the game thread in the Update hook. static bool g_editing = false; static GUIComponent* g_edit_component = nullptr; static toml_v2::config_file::config_entry_base* g_edit_entry = nullptr; static std::string g_edit_buffer; - static std::size_t g_edit_cursor = 0; // caret position as a byte index into g_edit_buffer - static bool g_edit_numeric = false; // restrict input to a numeric literal + static std::size_t g_edit_cursor = 0; // caret position as a byte index into g_edit_buffer. + static bool g_edit_numeric = false; // restrict input to a numeric literal. static bool g_edit_confirm = false; static bool g_edit_cancel = false; - // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the author (up to - // the first '-') and runs the mod name through key_to_display, so '_' becomes a space and - // camelCase / PascalCase word boundaries are split - the same friendly-name logic used for - // setting keys. "SGG_Modding-Chalk" -> "Chalk"; "NikkelM-Zagreus_Journey" -> "Zagreus Journey"; - // "zerp-DreamDiveTweaks" -> "Dream Dive Tweaks". + // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the author (up to the first '-') and + // runs the mod name through key_to_display, so '_' becomes a space and camelCase / PascalCase word boundaries are + // split - the same friendly-name logic used for setting keys "SGG_Modding-Chalk" -> "Chalk". + // "NikkelM-Zagreus_Journey" -> "Zagreus Journey" "zerp-DreamDiveTweaks" -> "Dream Dive Tweaks". static std::string key_to_display(const std::string& key); // shared friendly-name logic, defined below static std::string display_name_from_stem(const std::string& stem) @@ -448,8 +481,8 @@ namespace big::mod_settings return key_to_display(name); } - // The mod's Thunderstore manifest description, shown in the description box while its row in the - // mod list is highlighted. Empty when no loaded module matches the stem. + // The mod's Thunderstore manifest description, shown in the description box while its row in the mod list is + // highlighted. Empty when no loaded module matches the stem. static std::string mod_description_from_stem(const std::string& stem) { if (!big::g_lua_manager) @@ -467,23 +500,21 @@ namespace big::mod_settings return {}; } - // Shown in the description box in place of the mod description when a mod opted out of the in-game - // settings menu (rom.mod_settings.opt_out()), explaining why its row is greyed and where to - // configure it instead. + // Shown in the description box in place of the mod description when a mod opted out of the in-game settings menu + // (rom.mod_settings.opt_out()), explaining why its row is greyed and where to configure it instead. static std::string opt_out_note() { return "This mod opted out of the in-game settings menu. See the mod's own description for how " "to configure it, if applicable."; } - // Escapes the characters the game's text parser (GUIComponentTextBox::Parse) treats as markup, - // so arbitrary user text - config values (e.g. Windows paths with '\'), display names and - // descriptions - renders verbatim instead of being mangled. The parser reads '\' as an escape - // lead that consumes the following word ("D:\Program..." -> "D: ...") and '[' ']' as inline-tag - // delimiters whose contents are dropped ("[deprecated] x" -> " x"). A leading backslash makes - // each literal (\\ -> \, \[ -> [, \] -> ]); backslash MUST be escaped first. ('{' and '@' are - // also markup leads but have no literal escape in the parser, so are left as-is - they are rare - // in config text and, unlike '\'/'[', do not silently eat surrounding characters.) + // Escapes the characters the game's text parser (GUIComponentTextBox::Parse) treats as markup, so arbitrary user + // text - config values (e.g. Windows paths with '\'), display names and descriptions - renders verbatim instead of + // being mangled. The parser reads '\' as an escape lead that consumes the following word ("D:\Program..." -> "D: + // ...") and '[' ']' as inline-tag delimiters whose contents are dropped ("[deprecated] x" -> " x"). A leading + // backslash makes each literal (\\ -> \, \[ -> [, \] -> ]) backslash MUST be escaped first ('{' and '@' are also + // markup leads but have no literal escape in the parser, so are left as-is - they are rare in config text and, + // unlike '\'/'[', do not silently eat surrounding characters.). static std::string escape_markup(const std::string& text) { std::string out; @@ -499,13 +530,11 @@ namespace big::mod_settings return out; } - // --- Text metrics + caret helpers (byte indices into a string; UTF-8 aware) --- - - // Approximate width of a single byte in the value font, in the same units as - // value_display_max_width (medium glyph = 1.0). The menu font (P22UndergroundSCMedium) is - // variable-width; these rough classes are enough to fit values by visual width instead of raw - // character count (exact pixel measurement is intentionally avoided - it would need the engine's - // SpriteFont globals). A UTF-8 lead byte counts once as a medium glyph; continuation bytes add 0. + // --- Text metrics + caret helpers (byte indices into a string UTF-8 aware) --- Approximate width of a single byte + // in the value font, in the same units as value_display_max_width (medium glyph. = 1.0). The menu font + // (P22UndergroundSCMedium) is variable-width these rough classes are enough to fit values by visual width instead + // of raw character count (exact pixel measurement is intentionally avoided - it would need the engine's SpriteFont + // globals). A UTF-8 lead byte counts once as a medium glyph continuation bytes add 0. static float glyph_weight(unsigned char c) { if (c >= 0xC0) @@ -561,16 +590,15 @@ namespace big::mod_settings return w; } - // True for a "word" byte: ASCII alphanumeric, underscore, or any UTF-8 byte (>=0x80, so non-ASCII - // letters count as word characters). Used for Ctrl+Left/Right word skip. + // True for a "word" byte: ASCII alphanumeric, underscore, or any UTF-8 byte (>=0x80, so non-ASCII letters count as + // word characters). Used for Ctrl+Left/Right word skip. static bool is_word_byte(char c) { const unsigned char u = static_cast(c); return (u >= '0' && u <= '9') || (u >= 'A' && u <= 'Z') || (u >= 'a' && u <= 'z') || u == '_' || u >= 0x80; } - // Caret one codepoint to the left (skips UTF-8 continuation bytes so a multibyte char moves as - // a unit). + // Caret one codepoint to the left (skips UTF-8 continuation bytes so a multibyte char moves as a unit). static std::size_t caret_prev(const std::string& s, std::size_t pos) { if (pos == 0) @@ -600,8 +628,8 @@ namespace big::mod_settings return pos; } - // Caret to the start of the current/previous word (Ctrl+Left): skip any non-word bytes to the - // left, then the run of word bytes. + // Caret to the start of the current/previous word (Ctrl+Left): skip any non-word bytes to the left, then the run of + // word bytes. static std::size_t caret_prev_word(const std::string& s, std::size_t pos) { while (pos > 0 && !is_word_byte(s[pos - 1])) @@ -615,8 +643,8 @@ namespace big::mod_settings return pos; } - // Caret to the start of the next word (Ctrl+Right): skip the current run of word bytes, then the - // following non-word bytes. + // Caret to the start of the next word (Ctrl+Right): skip the current run of word bytes, then the following non-word + // bytes. static std::size_t caret_next_word(const std::string& s, std::size_t pos) { const std::size_t n = s.size(); @@ -631,11 +659,10 @@ namespace big::mod_settings return pos; } - // Caps an over-wide value string for the right-aligned value column so it does not run left into - // the option's key label. Keeps the TAIL with a leading ellipsis (most informative for a path, - // and where the append/backspace edit caret sits). Fits by summed glyph WIDTH, not character - // count, so wide/narrow text shows a consistent visual width. Operates on the logical (pre-escape) - // string; escape the result afterwards. + // Caps an over-wide value string for the right-aligned value column so it does not run left into the option's key + // label. Keeps the TAIL with a leading ellipsis (most informative for a path, and where the append/backspace edit + // caret sits). Fits by summed glyph WIDTH, not character count, so wide/narrow text shows a consistent visual + // width. Operates on the logical (pre-escape) string escape the result afterwards. static std::string truncate_value(const std::string& text) { if (measure_width(text) <= value_display_max_width) @@ -675,12 +702,12 @@ namespace big::mod_settings button->m_hidden = false; button->m_is_useable = true; - // Point the button's localization id at "Mods" so the engine's own label pipeline resolves it. - // The reused button ships with DisplayNameId "MiscSettingsScreen_EditorOptions" (-> "Editor"); - // interning "Mods" and writing its id into mDisplayNameId makes GUIComponentButton::UseDefaultText - // re-derive "Mods" natively - including after a language change, which re-runs that derivation and - // would otherwise revert the tab to "Editor". "Mods" has no text-data entry, so the lookup misses - // and the engine renders the raw key ("Mods") verbatim in every language. + // Point the button's localization id at "Mods" so the engine's own label pipeline resolves it. The reused + // button ships with DisplayNameId "MiscSettingsScreen_EditorOptions" (-> "Editor") interning "Mods" and writing + // its id into mDisplayNameId makes. GUIComponentButton::UseDefaultText re-derive "Mods" natively - including + // after a language change, which re-runs that derivation and would otherwise revert the tab to "Editor" "Mods" + // has no text-data entry, so the lookup misses and the engine renders the raw key ("Mods") verbatim in every + // language. if (g_hash_lookup) { HashGuid id{}; @@ -688,9 +715,9 @@ namespace big::mod_settings *reinterpret_cast(reinterpret_cast(button) + sgg::gui_component_button_display_name_id_offset) = id.m_id; } - // Apply the label now for the initial display: the original constructor already rendered the - // native "Editor" text from the old id, and UseDefaultText only re-derives on the next - // localization pass. Subsequent language changes are handled by the id above, not here. + // Apply the label now for the initial display: the original constructor already rendered the native "Editor" + // text from the native Editor id, and UseDefaultText only re-derives on the next localization pass. Subsequent + // language changes are handled by the id above, not here. if (g_set_label) { g_set_label(button, "Mods"); @@ -729,10 +756,9 @@ namespace big::mod_settings return row; } - // Links a finished row into the drawn/hit-tested (mComponents) and paged (mOptions) - // vectors, sets its X, and starts it transparent. UpdateScrollState only fades in and - // repositions on-page rows, so off-page rows must start invisible to avoid flashing - // stacked at the top. + // Links a finished row into the drawn/hit-tested (mComponents) and paged (mOptions) vectors, sets its X, and starts + // it transparent UpdateScrollState only fades in and repositions on-page rows, so off-page rows must start + // invisible to avoid flashing stacked at the top. static void finalize_row(MiscSettingsScreen* screen, GUIComponent* row, bool in_options = true) { GUIComponent* value = row; @@ -745,13 +771,13 @@ namespace big::mod_settings row->m_location_x = row_location_x; row->m_fade_opacity = 0.0f; + // Uniform opacity ease rate so every row type fades at the same native speed (see row_fade_speed). *reinterpret_cast(reinterpret_cast(row) + component_def_offset + def_fade_speed) = row_fade_speed; } - // Shows the on or off toggle graphic for a toggle row. The OptionToggleButton template - // stores both graphic hashes in the row's def (mGraphic = on, mAlternateGraphic = off); - // pick one and set it as the drawn texture. + // Shows the on or off toggle graphic for a toggle row. The OptionToggleButton template stores both graphic hashes + // in the row's def (mGraphic = on, mAlternateGraphic = off) pick one and set it as the drawn texture. static void set_toggle_graphic(GUIComponent* row, bool is_on) { if (!g_set_normal_texture) @@ -764,9 +790,8 @@ namespace big::mod_settings g_set_normal_texture(row, is_on ? on_hash : off_hash, false); } - // Dims a row's def text colours (both normal and selected) so a disabled row reads as - // greyed out and does not recolour on hover. Must be applied before SetupComponent so - // the change reaches the text box. + // Dims a row's def text colours (both normal and selected) so a disabled row reads as greyed out and does not + // recolour on hover. Must be applied before SetupComponent so the change reaches the text box. static void set_def_text_grey(GUIComponent* row) { char* def = reinterpret_cast(row) + component_def_offset; @@ -779,12 +804,11 @@ namespace big::mod_settings *reinterpret_cast(def + def_sel_text_blue) = grey; } - // Sets a row's normal text colour to the native settings-option grey (0.55) used by the - // game's own OptionToggleButton / OptionNumBox rows, so plain-text (key/value) rows built on - // the CategoryOptionsButton template (whose own text is a darker 0.35) match the toggle rows - // instead of reading as brighter full white. The selected colour is left as the template's - // (the same green highlight both templates use) so hover still highlights. Must run before - // SetupComponent to reach the text box. + // Sets a row's normal text colour to the native settings-option grey (0.55) used by the game's own. + // OptionToggleButton / OptionNumBox rows, so plain-text (key/value) rows built on the CategoryOptionsButton + // template (whose own text is a darker 0.35) match the toggle rows instead of reading as brighter full white. The + // selected colour is left as the template's (the same green highlight both templates use) so hover still + // highlights. Must run before SetupComponent to reach the text box. static void set_def_text_normal(GUIComponent* row) { char* def = reinterpret_cast(row) + component_def_offset; @@ -794,13 +818,12 @@ namespace big::mod_settings *reinterpret_cast(def + def_text_blue) = option_grey; } - // A plain left-justified text row (mod names, Back, and non-toggle settings). Applies a - // template for a valid font/colours, then retunes the row's own def into the key-rebind - // "ControlButton" style - no background graphic, left text, and a text-area hit region - // that hugs the label - and clears any leftover textures. Disabled rows are greyed; by - // default they are also hard-disabled (non-selectable). Pass block_input=false to grey a row - // while keeping it selectable, so it can still be highlighted to show its description (used for - // opted-out mods, whose row is greyed and shows a note but must not be drilled into). + // A plain left-justified text row (mod names, Back, and non-toggle settings). Applies a template for valid + // font/colours, then retunes the row's own def into the key-rebind "ControlButton" style - no background graphic, + // left text, and a text-area hit region that hugs the label - and clears any leftover textures. Disabled rows are + // greyed. By default they are also hard-disabled (non-selectable). Pass block_input=false to grey a row while + // keeping it selectable, so it can still be highlighted to show its description (used for opted-out mods, whose row + // is greyed and shows a note but must not be drilled into). static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) { auto* row = create_button(screen); @@ -817,7 +840,7 @@ namespace big::mod_settings *reinterpret_cast(def + def_add_text_area) = 1; // hit area follows the text *reinterpret_cast(def + def_use_text_area) = 0; // (union with the empty graphic area) *reinterpret_cast(def + def_graphic) = 0; // no button background - *reinterpret_cast(def + def_selected_graphic) = 0; // no highlight box (text recolours instead) + *reinterpret_cast(def + def_selected_graphic) = 0; // no *reinterpret_cast(def + def_alternate_graphic) = 0; *reinterpret_cast(def + def_width) = 0.0f; // let the text drive the area *reinterpret_cast(def + def_height) = 0.0f; @@ -840,8 +863,8 @@ namespace big::mod_settings g_setup_component(row, row_bytes + component_data_offset); } - // SetupComponent applies our zeroed graphic fields but does not actively tear down - // the normal/selected textures a prior template already set. Clear them explicitly. + // SetupComponent applies our zeroed graphic fields but does not actively tear down the normal/selected textures + // a prior template already set. Clear them explicitly. if (g_set_normal_texture) { g_set_normal_texture(row, 0, false); @@ -869,10 +892,9 @@ namespace big::mod_settings return row; } - // A toggle row (boolean setting): a left-justified label plus the native on/off toggle - // switch graphic on the right. The OptionToggleButton template already supplies the - // toggle graphic, left-justified text and text area; we only realign it to our row grid - // (mY/mSpacing, read directly by UpdateScrollState) and choose the on/off graphic. + // A toggle row (boolean setting): a left-justified label plus the native on/off toggle switch graphic on the right. + // The OptionToggleButton template already supplies the toggle graphic, left-justified text and text area we only + // realign it to our row grid (mY/mSpacing, read directly by UpdateScrollState) and choose the on/off graphic. // Disabled rows are greyed and made non-interactable. static GUIComponent* make_toggle_row(MiscSettingsScreen* screen, const char* label, bool is_on, bool disabled = false) { @@ -890,10 +912,9 @@ namespace big::mod_settings *reinterpret_cast(def + def_y) = row_base_y; *reinterpret_cast(def + def_spacing) = row_pitch; - // Greying needs a SetupComponent pass to reach the text box and button colour; the - // toggle graphic is re-chosen afterwards so the pass does not revert it. The button - // tint is switched from additive to a multiplicative dim so the toggle graphic reads - // as greyed rather than full brightness. + // Greying needs a SetupComponent pass to reach the text box and button colour. The toggle graphic is re-chosen + // afterwards so the pass does not revert it. The button tint is switched from additive to a multiplicative dim + // so the toggle graphic reads as greyed rather than full brightness. if (disabled) { set_def_text_grey(row); @@ -923,11 +944,13 @@ namespace big::mod_settings return row; } - // A centered native button row (for actions like Apply/Reset), using the - // CategoryOptionsButton template unchanged so it keeps its Button_Secondary box graphic - // and centered label - visually distinct from the plain-text setting rows. Only the row - // grid position (mY/mSpacing) is overridden. - static GUIComponent* make_button_row(MiscSettingsScreen* screen, const char* label, bool disabled = false) + // A centered native button row (for actions like Apply/Reset), using the CategoryOptionsButton template unchanged + // so it keeps its Button_Secondary box graphic and centered label - visually distinct from the plain-text setting + // rows. Only the row grid position (mY/mSpacing) is overridden. Disabled rows are greyed by default they are also + // hard-disabled (non-selectable). Pass block_input=false to grey a row while keeping it selectable, so it can still + // be highlighted to show its description note (used for a context-restricted action, which is greyed but must still + // explain why. It is unavailable). + static GUIComponent* make_button_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) { auto* row = create_button(screen); if (!row) @@ -939,16 +962,28 @@ namespace big::mod_settings set_sso_string(row_bytes + gui_component_name_offset, "CategoryOptionsButton"); g_apply_data(reinterpret_cast(screen), row); + // Stretch. The box only enough to fit a label wider than the native box (see button_label_*), so short labels + // keep the clean native box. Drawn box width = native * mScale * box_scale_x. + const float box_scale_x = std::max(1.0f, (measure_width(label) + button_label_padding) / button_label_capacity); + + constexpr float button_scale = 0.8f; + char* def = row_bytes + component_def_offset; *reinterpret_cast(def + def_y) = row_base_y; *reinterpret_cast(def + def_spacing) = row_pitch; - *reinterpret_cast(def + def_offset_y) = 0.0f; // drop the template's built-in vertical offset - *reinterpret_cast(def + def_scale) = 0.85f; // shrink slightly for top/bottom breathing room - // Size the hit-test rect to cover the whole visible (scaled) button; the template's - // 280x40 was smaller than the Button_Secondary graphic, which cut off hover top/bottom. - *reinterpret_cast(def + def_width) = 340.0f; + *reinterpret_cast(def + def_offset_y) = 0.0f; // drop the template's built-in vertical offset + *reinterpret_cast(def + def_scale) = button_scale; // shrink slightly for top/bottom breathing room + + // Match the hit-test rect to the visible (stretched) box so hover/click line up with what is drawn. + *reinterpret_cast(def + def_width) = button_graphic_native_width * button_scale * box_scale_x; *reinterpret_cast(def + def_height) = 58.0f; + // Momentary selection: the CategoryOptionsButton template keeps a button selected (its highlight lit) after a + // mouse-off - correct for the category tabs, but an action button should not stay lit like a selected tab once + // clicked mDeselectOnMouseOff makes the highlight clear when the cursor leaves (the highlight still shows while + // hovered), so the action button reads as momentary. + *reinterpret_cast(def + def_deselect_on_mouse_off) = true; + if (disabled) { set_def_text_grey(row); @@ -964,25 +999,46 @@ namespace big::mod_settings g_set_label(row, label); } - if (disabled && g_disable) + // Widen. The box graphic to box_scale_x. The box is a single-frame animation reached via mAnim enabling + // mScaleModifierOnlyX makes GUIComponentAnimation::Draw honour the anim's own def mScaleX (horizontal-only), + // which the button otherwise leaves at a uniform scale. The selection highlight (mSelectedTexture, drawn as an + // overlay) is instead scaled by the BUTTON's own def mScaleX/mScaleY (the button's Drawable), independent of + // the box's mAnim - so set those too, by the same factor, to keep the highlight's designed glow margin around + // the widened box. + if (box_scale_x > 1.0f) + { + // component_def_scale_* offsets are from the component base + *reinterpret_cast(row_bytes + component_def_scale_x_offset) = box_scale_x; + *reinterpret_cast(row_bytes + component_def_scale_y_offset) = 1.0f; + + if (void* anim = *reinterpret_cast(row_bytes + button_anim_offset); anim) + { + char* anim_bytes = reinterpret_cast(anim); + *reinterpret_cast(anim_bytes + anim_scale_modifier_only_x_offset) = true; + // component_def_scale_* offsets are from the component base + *reinterpret_cast(anim_bytes + component_def_scale_x_offset) = box_scale_x; + *reinterpret_cast(anim_bytes + component_def_scale_y_offset) = 1.0f; + } + } + + if (disabled && block_input && g_disable) { g_disable(row); } finalize_row(screen, row); - // Centre the button in the content pane (finalize_row anchors rows at the right-hand - // option column, which would put the button over the scrollbar). + // Centre the button in the content pane (finalize_row anchors rows at the right-hand option column, which would + // put the button over the scrollbar). row->m_location_x = button_center_x; return row; } - // A right-justified, non-interactive value label for the right column of a key/value - // setting row (paired with a left-column key row). It is NOT added to mOptions: the - // engine's scroll pass lays out only mOptions rows by index and would stack a second - // per-row entry, so instead the value follows its key row each frame (sync_value_columns). - // It shares the key's component X anchor but uses RIGHT justification, so the value sits in - // the right column while the key stays left. + // A right-justified, non-interactive value label for the right column of a key/value setting row (paired with a + // left-column key row). It is NOT added to mOptions: the engine's scroll pass lays out only mOptions rows by index + // and would stack a second per-row entry, so instead the value follows its key row each frame (sync_value_columns). + // It shares the key's component X anchor but uses RIGHT justification, so the value sits in the right column while + // the key stays left. static GUIComponent* make_value_display(MiscSettingsScreen* screen, const char* text, bool disabled) { auto* row = create_button(screen); @@ -1038,7 +1094,7 @@ namespace big::mod_settings g_set_label(row, text); } - row->m_can_be_focused = false; // never interactive; the empty hit area blocks hover/click + row->m_can_be_focused = false; // never interactive, the empty hit area blocks hover/click finalize_row(screen, row, false); // drawn (mComponents) but not paged (mOptions) return row; @@ -1050,8 +1106,8 @@ namespace big::mod_settings return std::isfinite(v) && v == std::floor(v); } - // Overrides a num-box's centered value text (its mValueTextBox) with an enum option label. The - // label is escaped so paths/brackets in the option text render verbatim (see escape_markup). + // Overrides a num-box's centered value text (its mValueTextBox) with an enum option label. The label is escaped so + // paths/brackets in the option text render verbatim (see escape_markup). static void set_numbox_value_text(GUIComponent* numbox, const char* text) { if (!g_show_text || !numbox) @@ -1064,15 +1120,14 @@ namespace big::mod_settings } } - // Builds a native sgg::GUIComponentNumBox stepper row - identical to the game's own FPS-limit / - // graphics-quality options (boxed value flanked by Arrow_Left/Arrow_Right, left/right + arrow-click - // stepping, keyboard + controller). The game's factory allocates it, sets the correct vtable and - // builds all five sub-components (box graphic, label, value text, both arrows), which are also - // freed automatically when the row vectors are torn down - so no manual cleanup is needed. Value - // edits are persisted by the SetNumberValue hook (filtered to our rows). Returns the num-box - // component (not a GUIComponentButton, so it never routes through the OnClicked hook). When - // `value_labels` is non-null the box is an enum cycler: it steps the integer index and its value - // text is overridden to the matching label instead of the raw number. + // Builds a native sgg::GUIComponentNumBox stepper row - identical to the game's own FPS-limit / graphics-quality + // options (boxed value flanked by Arrow_Left/Arrow_Right, left/right + arrow-click stepping, keyboard + + // controller). The game's factory allocates it, sets the correct vtable and builds all five sub-components (box + // graphic, label, value text, both arrows), which are also freed automatically when the row vectors are torn down - + // so no manual cleanup is needed. Value edits are persisted by the SetNumberValue hook (filtered to our rows). + // Returns the num-box component (not a GUIComponentButton, so it never routes through the OnClicked hook). When + // `value_labels` is non-null. The box is an enum cycler: it steps the integer index and its value text is + // overridden to the matching label instead of the raw number. static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled, const std::vector* value_labels = nullptr) { if (!g_numbox_factory || !g_numbox_set_range || !g_numbox_set_value || !g_apply_data || !g_show_text) @@ -1088,8 +1143,8 @@ namespace big::mod_settings } char* nb_bytes = reinterpret_cast(nb); - // Name the box and its sub-components so ApplyDataToComponent applies the matching sjson - // templates (its virtual ApplyDataToName routes each def by the sub-component's mName). + // Name. The box and its sub-components so ApplyDataToComponent applies the matching sjson templates (its + // virtual. ApplyDataToName routes each def by the sub-component's mName). set_sso_string(nb_bytes + gui_component_name_offset, "OptionNumBox"); if (void* value_tb = *reinterpret_cast(nb_bytes + numbox_value_text_offset)) { @@ -1104,9 +1159,9 @@ namespace big::mod_settings set_sso_string(static_cast(right_arrow) + gui_component_name_offset, "OptionNumBoxRightArrow"); } - // Integer box when the bounds and step are all whole (shows "3" not "3.0" and uses the discrete - // single-step path); otherwise a float box (decimals + analog repeat). Set the flag BEFORE - // SetRange, whose auto-step derives from it, then pin our own step. + // Integer box when the bounds and step are all whole (shows "3" not "3.0" and uses the discrete single-step + // path) otherwise a float box (decimals + analog repeat). Set the flag BEFORE SetRange, whose auto-step. + // Derives from it, then pin our own step. const bool is_integer = is_whole(min_v) && is_whole(max_v) && is_whole(step_v); *reinterpret_cast(nb_bytes + numbox_is_integer_offset) = is_integer; @@ -1115,10 +1170,10 @@ namespace big::mod_settings g_apply_data(reinterpret_cast(screen), nb); - // ApplyDataToComponent copies the OptionNumBox template's own row grid (Y=300, Spacing=45) - // into the component; override it to our grid so the box lines up with the other rows instead - // of drawing on the previous one. def_y/def_spacing alias the component's baseY(+0xC8) and - // pitch(+0x204) that UpdateScrollState reads (def sits at component+0xA8). + // ApplyDataToComponent copies the OptionNumBox template's own row grid (Y=300, Spacing=45) into the component + // override it to our grid so the box lines up with the other rows instead of drawing on the previous one + // def_y/def_spacing alias the component's baseY(+0xC8) and pitch(+0x204) that UpdateScrollState reads (def sits + // at component+0xA8). { char* def = nb_bytes + component_def_offset; *reinterpret_cast(def + def_y) = row_base_y; @@ -1131,10 +1186,10 @@ namespace big::mod_settings g_show_text(label_tb, label); } - // Paint the starting value; notify=false so the SetNumberValue hook does not persist it. + // Paint the starting value notify=false so the SetNumberValue hook does not persist it. g_numbox_set_value(nb, static_cast(initial), false); - // Enum cycler: replace the raw index the box just painted with the option's label. + // Enum cycler: replace the raw index. The box just painted with the option's label. if (value_labels && !value_labels->empty()) { int idx = static_cast(initial); @@ -1159,10 +1214,10 @@ namespace big::mod_settings return nb; } - // Formats a numeric setting value for display. is_pct shows a 0..1 value as 0..100 and appends "%"; - // show_as_pct only appends "%" (no scaling). Setting both is the same as is_pct alone. The value is - // rounded to the display step's precision so scaling by 100 does not surface floating-point noise, - // then trailing zeros are trimmed ("53", "0.5", "50%"). + // Formats a numeric setting value for display. is_pct shows a 0..1 value as 0..100 and appends "%". show_as_pct + // only appends "%" (no scaling). Setting both is the same as is_pct alone. The value is rounded to the display + // step's precision so scaling by 100 does not surface floating-point noise, then trailing zeros are trimmed ("53", + // "0.5", "50%"). static std::string format_setting_display(double value, bool show_as_pct, bool is_pct, double step) { double shown = is_pct ? value * 100.0 : value; @@ -1195,8 +1250,8 @@ namespace big::mod_settings return out; } - // Sets the slider's right-hand value text (mValueTextBox). The native drag handler rewrites this to - // a percentage on every change, so we re-apply the setting's real value after each user edit. + // Sets the slider's right-hand value text (mValueTextBox). The native drag handler rewrites this to a percentage on + // every change, so we re-apply the setting's real value after each user edit. static void set_slider_value_text(GUIComponent* slider, const char* text) { if (!g_show_text || !slider) @@ -1209,17 +1264,16 @@ namespace big::mod_settings } } - // Builds a native sgg::GUIComponentSlider row - the horizontal drag bar the audio-volume options - // use - for a bounded numeric setting. The slider stores a normalized 0..1 fraction; we map the - // setting's [min,max] onto it and snap drags to `step` in the SetFraction hook. The engine has no - // factory for this type, so this replicates the construction DoShowCategory performs for the volume - // rows: allocate the block, run the base GUIComponent constructor, install the slider vtable, zero - // the fields Defaults leaves untouched, run Defaults, then allocate and construct the four owned - // sub-components (bar background, fill, label, value text). Named "OptionSlider" so - // ApplyDataToComponent applies the matching sjson template (bar graphics, colours, FadeSpeed, label - // styling). Teardown mirrors the num-box: destroy_rows routes it through the vtable deleting - // destructor (which frees the sub-components) then _aligned_free. Returns null if any required engine - // helper is missing, in which case the caller falls back to a number-box stepper. + // Builds a native sgg::GUIComponentSlider row - the horizontal drag bar the audio-volume options use - for a + // bounded numeric setting. The slider stores a normalized 0..1 fraction. We map the setting's [min,max] onto it and + // snap drags to `step` in the SetFraction hook. The engine has no factory for this type, so this replicates the + // construction DoShowCategory performs for the volume rows: allocate the block, run the base GUIComponent + // constructor, install the slider vtable, zero the fields Defaults leaves untouched, then run Defaults and allocate + // the four owned sub-components (bar background, fill, label, value text). Named "OptionSlider" so + // ApplyDataToComponent applies the matching sjson template (bar graphics, colours, FadeSpeed, label styling). + // Teardown mirrors the num-box: destroy_rows routes it through the vtable deleting destructor (which frees the + // sub-components) then _aligned_free. Returns null if any required engine helper is missing, in which case the + // caller falls back to a number-box stepper. static GUIComponent* make_slider_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool show_as_pct, bool is_pct, bool disabled) { if (!g_gui_component_ctor || !g_image_ctor || !g_textbox_ctor || !g_slider_defaults || !g_slider_set_fraction || !g_slider_vtable || !g_apply_data || !g_show_text) @@ -1234,13 +1288,13 @@ namespace big::mod_settings } std::memset(s, 0, slider_sizeof); - // Base GUIComponent constructor (location passed by value; 0 = origin, overridden below by - // ApplyDataToComponent / finalize_row), then install the slider vtable over the base one. + // Base GUIComponent constructor (location passed by value. 0 = origin, overridden below by + // ApplyDataToComponent. / finalize_row), then install the slider vtable over the base one. g_gui_component_ctor(s, 0); *reinterpret_cast(s) = g_slider_vtable; - // Defaults does not initialise mOnValueChanged or mValueTextBox, so zero them (the block is - // freshly malloc'd) before Defaults runs and before anything reads them. + // Defaults does not initialise mOnValueChanged or mValueTextBox, so zero them (the block is freshly malloc'd) + // before Defaults runs and before anything reads them. std::memset(s + slider_on_changed_offset, 0, 3 * sizeof(void*)); *reinterpret_cast(s + slider_label_offset) = nullptr; *reinterpret_cast(s + slider_value_text_offset) = nullptr; @@ -1248,8 +1302,8 @@ namespace big::mod_settings g_slider_defaults(s); *reinterpret_cast(s + slider_owner_offset) = screen; - // Four owned sub-components, each allocated then constructed at the origin (as the game does): - // two images (bar background + fill) and two text boxes (left label + right value). + // Four owned sub-components, each allocated then constructed at the origin (as the game does): two images (bar + // background + fill) and two text boxes (left label + right value). char* backing = static_cast(_aligned_malloc(image_sizeof, 8)); char* fill = static_cast(_aligned_malloc(image_sizeof, 8)); char* lbl = static_cast(_aligned_malloc(textbox_sizeof, 8)); @@ -1272,18 +1326,18 @@ namespace big::mod_settings *reinterpret_cast(s + slider_label_offset) = lbl; *reinterpret_cast(s + slider_value_text_offset) = val; - // Parent container, matching DoShowCategory. SetParent is a plain setter (writes - // mParentContainer), so a direct write is equivalent and avoids a vtable call. + // Parent container, matching DoShowCategory SetParent is a plain setter (writes mParentContainer), so a direct + // write is equivalent and avoids a vtable call. SetParent writes. GUIComponent::GUIComponent::mParentContainer. *reinterpret_cast(s + slider_parent_offset) = reinterpret_cast(screen) + menu_screen_container_offset; - // Name the slider and its value box so ApplyDataToComponent applies the OptionSlider / - // OptionSliderValueText templates (bar graphics, colours, FadeSpeed and the label styling). + // Name the slider and its value box so ApplyDataToComponent applies the OptionSlider / OptionSliderValueText + // templates (bar graphics, colours, FadeSpeed and the label styling). set_sso_string(s + gui_component_name_offset, "OptionSlider"); set_sso_string(val + gui_component_name_offset, "OptionSliderValueText"); if (disabled) { - set_def_text_grey(reinterpret_cast(s)); // grey before ApplyData so it reaches the text boxes + set_def_text_grey(reinterpret_cast(s)); // grey. } g_apply_data(reinterpret_cast(screen), reinterpret_cast(s)); @@ -1300,8 +1354,8 @@ namespace big::mod_settings g_show_text(label_tb, label); } - // Paint the starting value: map [min,max] -> 0..1 and set the fraction without notifying (so the - // SetFraction hook does not treat it as a user edit), then show the real value (not a percentage). + // Paint the starting value: map [min,max] -> 0..1 and set the fraction without notifying (so the SetFraction + // hook does not treat it as a user edit), then show the real value (not a percentage). const double range = max_v - min_v; const float frac = (range > 0.0) ? static_cast((initial - min_v) / range) : 0.0f; g_slider_set_fraction(s, frac, false); @@ -1318,9 +1372,9 @@ namespace big::mod_settings return reinterpret_cast(s); } - // Removes the first pointer equal to `value` from an eastl vector by shifting the tail - // down in place - the same unlink the engine's DoShowCategory performs. No-op if not - // present; the backing storage is left owned by the vector. + // Removes the first pointer equal to `value` from an eastl vector by shifting the tail down in place - the same + // unlink the engine's DoShowCategory performs. No-op if not present. The backing storage is left owned by the + // vector. static void vector_erase(sgg::eastl_vector& vec, GUIComponent* value) { for (GUIComponent** it = vec.m_begin; it != vec.m_end; ++it) @@ -1334,11 +1388,10 @@ namespace big::mod_settings } } - // Tears down every custom row we currently own: clears any screen pointer that still - // references a row (so the engine cannot dereference it after free), unlinks it from - // the drawn/hit-tested mComponents and the paged mOptions, then destroys and frees it. - // Our rows are not registered in the reflection helper, so the engine never frees them - // and never double-frees here. Safe to call when g_rows is empty or already unlinked. + // Tears down every custom row we currently own: clears any screen pointer that still references a row (so the + // engine cannot dereference it after free), unlinks it from the drawn/hit-tested mComponents and the paged + // mOptions, then destroys and frees it. Our rows are not registered in the reflection helper, so the engine never + // frees them and never double-frees here. Safe to call when g_rows is empty or already unlinked. static void destroy_rows(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); @@ -1378,10 +1431,10 @@ namespace big::mod_settings if (owns_subcomponents) { - // The num-box and slider are not GUIComponentButtons; destruct through the component's - // own vtable so its owned sub-components (num-box: box/label/value/arrows; slider: - // background/fill/label/value) are freed too. flags=0 destructs without the final - // operator delete, so we still _aligned_free the block ourselves. + // The num-box and slider are not GUIComponentButtons destruct through the component's own vtable so its + // owned sub-components (num-box: box/label/value/arrows slider: background/fill/label/value) are freed + // too flags=0 destructs without the final operator delete, so we still _aligned_free the block + // ourselves. void** vtbl = *reinterpret_cast(comp); auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset / sizeof(void*)]); dtor(comp, 0); @@ -1412,6 +1465,7 @@ namespace big::mod_settings { continue; } + // H2M's own framework config is not a mod the user configures here. if (cfg->m_config_file_stem_as_str == "Hell2Modding-Hell2Modding-General") { @@ -1438,11 +1492,10 @@ namespace big::mod_settings for (const auto& [display, stem] : mods) { - // A mod that called rom.mod_settings.opt_out() is still listed (dropping it would look like - // a missing mod), but its row is greyed and cannot be opened, and its description is a note - // pointing back to the mod's own description. The row is greyed without hard-disabling it so - // it stays selectable and the note still shows on hover/focus; the drilldown is blocked by - // the disabled flag in the click handler. + // A mod that called rom.mod_settings.opt_out() is still listed (dropping it would look like a missing mod), + // but its row is greyed and cannot be opened, and its description is a note pointing back to the mod's own + // description. The row is greyed without hard-disabling it so it stays selectable and the note still shows + // on hover/focus. The drilldown is blocked by the disabled flag in the click handler. const bool opted_out = mod_opted_out(stem); if (auto* row = make_text_row(screen, escape_markup(display).c_str(), opted_out, /*block_input*/ false)) { @@ -1454,12 +1507,11 @@ namespace big::mod_settings } } - // Turns an identifier into a friendly display string: underscores become spaces, and camelCase / - // PascalCase word boundaries are split ("z_ThisConfigKey" -> "z This Config Key"). An acronym run - // splits before its final capital when that capital starts a lowercase word ("HTTPServer" -> - // "HTTP Server"). The first letter is capitalized ("enabled" -> "Enabled"). Used for both setting - // keys and mod names (via display_name_from_stem). Authors can override this entirely with - // `display_name`. + // Turns an identifier into a friendly display string: underscores become spaces, and camelCase / PascalCase word + // boundaries are split ("z_ThisConfigKey" -> "z. This Config Key"). An acronym run splits before its final capital + // when that capital starts a lowercase word ("HTTPServer" -> "HTTP. Server"). The first letter is capitalized + // ("enabled" -> "Enabled"). Used for both setting keys and mod names (via display_name_from_stem). Authors can + // override this entirely with `display_name`. static std::string key_to_display(const std::string& key) { const auto is_upper = [](char c) @@ -1494,8 +1546,8 @@ namespace big::mod_settings out.push_back(c); } - // Capitalize the first letter so a key/mod name with no author display_name still reads as a - // proper title ("enabled" -> "Enabled"). + // Capitalize the first letter so a key/mod name with no author display_name still reads as a proper title + // ("enabled" -> "Enabled"). for (char& c : out) { if (c != ' ') @@ -1510,12 +1562,11 @@ namespace big::mod_settings return out; } - // Renders the edit buffer with a caret marker at `cursor`, windowed by visual WIDTH so the caret - // stays visible and the whole string fits the value column (value_display_max_width) without - // running into the key label. The window grows outward from the caret (both sides) filling the - // budget by summed glyph width, reserving space for the caret and for whichever ellipses are - // actually shown. The caret is a blinking "|"/" "; hidden text is marked with a leading/trailing - // ellipsis. Each shown buffer segment is markup-escaped (a path may contain '\'); the caret and + // Renders the edit buffer with a caret marker at `cursor`, windowed by visual WIDTH so the caret stays visible and + // the whole string fits the value column (value_display_max_width) without running into the key label. The window + // grows outward from the caret (both sides) filling the budget by summed glyph width, reserving space for the caret + // and for whichever ellipses are actually shown. The caret is a blinking ". "/" " hidden text is marked with a + // leading/trailing ellipsis. Each shown buffer segment is markup-escaped (a path may contain '\'). The caret and // ellipses are literal. static std::string render_edit_display(const std::string& buf, std::size_t cursor, bool blink_on) { @@ -1535,9 +1586,9 @@ namespace big::mod_settings return escape_markup(buf.substr(0, cursor)) + caret + escape_markup(buf.substr(cursor)); } - // Grow a window [start, end) outward from the caret, one codepoint at a time, alternating - // left then right, while it still fits the budget (accounting for the ellipses each side will - // need). Left grows first each round so a right-aligned field shows preceding context. + // Grow a window [start, end) outward from the caret, one codepoint at a time, alternating left then right, + // while it still fits the budget (accounting for the ellipses each side will need). Left grows first each round + // so a right-aligned field shows preceding context. std::size_t start = cursor; std::size_t end = cursor; float used = caret_w; @@ -1587,9 +1638,9 @@ namespace big::mod_settings return out; } - // Accepts a character into a numeric edit buffer only if the result stays a plausible - // numeric literal: an optional leading sign (only at the front), digits, at most one decimal - // point. `cursor` is where the character would be inserted. + // Accepts a character into a numeric edit buffer only if the result stays a plausible numeric literal: an optional + // leading sign (only at the front), digits, at most one decimal point `cursor` is where the character would be + // inserted. static bool numeric_char_ok(const std::string& buffer, std::size_t cursor, char c) { if (c >= '0' && c <= '9') @@ -1603,17 +1654,16 @@ namespace big::mod_settings } if (c == '.') { - return buffer.find('.') == std::string::npos; // a single decimal point + return buffer.find('.') == std::string::npos; // a single decimal point. } return false; } - // Window-procedure callback: while a freetext setting is being edited, capture typed characters - // and caret movement into the edit buffer. Runs on the game's message-pump thread (same thread - // as Update). Printable characters arrive via WM_CHAR (inserted at the caret); Backspace/Delete, - // arrow movement (with Ctrl for word skip), Home/End via WM_KEYDOWN; a mouse click anywhere - // commits the edit (Enter/Escape are read from the game input in the HandleInput hook, which - // also blocks the menu from reacting). + // Window-procedure callback: while a freetext setting is being edited, capture typed characters and caret movement + // into the edit buffer. Runs on the game's message-pump thread (same thread as Update). Printable characters arrive + // via WM_CHAR (inserted at the caret). Backspace/Delete, arrow movement (with Ctrl for word skip), Home/End via. + // WM_KEYDOWN. A mouse click anywhere commits the edit (Enter/Escape are read from the game input in the HandleInput + // hook, which also blocks the menu from reacting). static void on_wndproc(HWND, UINT msg, WPARAM wparam, LPARAM) { if (!g_editing) @@ -1683,7 +1733,7 @@ namespace big::mod_settings } } - // Registers on_wndproc with the framework's window hook the first time it is needed. + // Registers on_wndproc with the framework's window hook the first time. It is needed static void ensure_wndproc_registered() { static bool registered = false; @@ -1730,10 +1780,9 @@ namespace big::mod_settings return stem + '\0' + entry->m_definition.m_section + '\0' + entry->m_definition.m_key; } - // Captures a restart-required setting's baseline (its value as of this menu session's open) - // BEFORE it is first modified, so a later change back to this value can be recognised as "no - // net change". Called just before the value is written. No-op for non-restart-required settings - // and after the first capture for a given setting. + // Captures a restart-required setting's baseline (its value as of this menu session's open) BEFORE It is first + // modified, so a later change back to this value can be recognised as "no net change". Called just before the value + // is written. No-op for non-restart-required settings and after the first capture for a given setting. static void capture_restart_baseline(toml_v2::config_file::config_entry_base* entry) { if (!entry || !entry->m_config_file) @@ -1756,11 +1805,10 @@ namespace big::mod_settings return g_config_language ? std::string(g_config_language) : std::string(); } - // Resolves a localized string to the current game language: the entry for the current language code, - // then English, then the unlocalized value (empty key), then any entry. A plain (unlocalized) string - // is stored as the single empty-key entry and returned as-is. Returns "" when there is nothing to - // show. Resolution happens here (render time), so re-entering the tab after a language change picks - // up the new language. + // Resolves a localized string to the current game language: the entry for the current language code, then English, + // then the unlocalized value (empty key), then any entry. A plain (unlocalized) string is stored as the single + // empty-key entry and returned as-is. Returns "" when there is nothing to show. Resolution happens here (render + // time), so re-entering the tab after a language change picks up the new language. static std::string resolve_localized(const localized_text& t) { if (t.empty()) @@ -1786,11 +1834,35 @@ namespace big::mod_settings return t.begin()->second; } - // The friendly display name for a setting: the author's `display_name` override when provided, - // otherwise the prettified key. Mirrors how the setting rows are labelled. + // True if the current settings view has any row with a dynamic (Lua-function) description field. Recomputed each + // build (see build_panel). Consulted when a bool toggle changes so the panel is rebuilt in place to re-evaluate + // dynamic disabled/ranges/options against the new value. + static bool g_view_has_dynamic = false; + + // A setting's metadata with any dynamic (Lua-function) description fields evaluated against the current game state. + // Identical to get_setting_metadata for static settings resolves live values (slider bounds, enum options, hidden, + // disabled, display name, ...) when the setting declares any function field. Must be called on the game thread + // while the Lua state is valid, as the menu build is live. Records that the view has a dynamic row so a later + // toggle can rebuild to re-evaluate it. + static std::optional resolved_metadata(const std::string& stem, const std::string& section, const std::string& key) + { + auto meta = get_setting_metadata(stem, section, key); + if (meta && meta->has_dynamic) + { + g_view_has_dynamic = true; + if (auto dynamic = resolve_setting_metadata(stem, section, key)) + { + return dynamic; + } + } + return meta; + } + + // The friendly display name for a setting: the author's `display_name` override when provided, otherwise the + // prettified key. Mirrors how the setting rows are labelled. static std::string setting_display_name(const std::string& stem, const std::string& section, const std::string& key) { - const auto meta = get_setting_metadata(stem, section, key); + const auto meta = resolved_metadata(stem, section, key); if (meta) { if (std::string name = resolve_localized(meta->name); !name.empty()) @@ -1801,11 +1873,10 @@ namespace big::mod_settings return key_to_display(key); } - // Records or clears a restart-required setting change after the value has been written. If the - // new value equals the session baseline (e.g. a toggle flipped and flipped back, or a number - // re-typed to its original), nothing actually changed, so the setting is dropped from the - // restart list; otherwise it is listed. `new_value_display` is the value shown in the popup. - // g_restart_required stays set as long as any real change remains. + // Records or clears a restart-required setting change after the value has been written. If the new value equals the + // session baseline (e.g. a toggle flipped and flipped back, or a number re-typed to its original), nothing actually + // changed, so the setting is dropped from the restart list otherwise. It is listed `new_value_display` is the value + // shown in the popup g_restart_required stays set as long as any real change remains. static void note_change_if_restart_required(toml_v2::config_file::config_entry_base* entry, const std::string& new_value_display) { if (!entry || !entry->m_config_file) @@ -1822,12 +1893,12 @@ namespace big::mod_settings const auto baseline = g_restart_baselines.find(key); if (baseline != g_restart_baselines.end() && entry->get_serialized_value() == baseline->second) { - // Reverted to the session baseline: no net change, so it no longer needs a restart. + // Matches the session baseline, so it has no net restart requirement g_restart_changes.erase(key); } else { - // Stored plain; word-wrapped with regular spaces for the dialog in build_restart_message. + // Stored plain word-wrapped with regular spaces for the dialog in build_restart_message. const std::string line = display_name_from_stem(stem) + ": " + setting_display_name(stem, entry->m_definition.m_section, entry->m_definition.m_key) + " (" + new_value_display + ")"; g_restart_changes[key] = line; @@ -1836,9 +1907,9 @@ namespace big::mod_settings g_restart_required = !g_restart_changes.empty(); } - // Refreshes a freetext row's right-column value display to show `serialized`, formatted exactly - // as build_mod_settings renders it (width-truncated with a leading ellipsis, then markup-escaped). - // Used to reflect a committed or cancelled edit in place, without a panel rebuild. + // Refreshes a freetext row's right-column value display to show `serialized`, formatted exactly as + // build_mod_settings renders it (width-truncated with a leading ellipsis, then markup-escaped). Used to reflect a + // committed or cancelled edit in place, without a panel rebuild. static void refresh_value_display(GUIComponent* value_component, const std::string& serialized) { if (value_component && g_set_label) @@ -1848,33 +1919,32 @@ namespace big::mod_settings } } - // Commits or cancels a pending edit. Called from the HandleInput hook so it runs on the - // same frame the triggering key/click is swallowed (HandleInput returns true that - // frame), which prevents a submitting mouse click from also activating the row it lands - // on. Returns true if the edit ended this call. + // Commits or cancels a pending edit. Called from the HandleInput hook so it runs on the same frame the triggering + // key/click is swallowed (HandleInput returns true that frame), which prevents a submitting mouse click from also + // activating the row it lands on. Returns true if the edit ended this call. static bool commit_or_cancel_edit() { if (g_edit_confirm) { if (g_edit_entry) { - // Capture the session baseline before the first write so a later revert to it - // is recognised as "no net change". + // Capture the session baseline before the first write so a later revert is recognised as "no net + // change". capture_restart_baseline(g_edit_entry); - // set_serialized_value validates (e.g. numbers) and only stores/saves a - // valid value, so bad input for a number simply keeps the old value. + // set_serialized_value validates (e.g. numbers) and only stores/saves a valid value, so bad input for a + // number simply keeps the previous value. g_edit_entry->set_serialized_value(g_edit_buffer); - // Clamp/snap a bounded number typed via freetext to match what the stepper would - // produce: keep it within [min, max] and, if a step is declared, snap to the nearest - // grid point min + k*step. (The native stepper enforces both; freetext does it on - // commit.) set_serialized_value above already parsed/validated the number. + // Clamp/snap a bounded number typed via freetext to match what the stepper would produce: keep it + // within [min, max] and, if a step is declared, snap to the nearest grid point min + k*step (The native + // stepper enforces both freetext does it on commit.) set_serialized_value above already + // parsed/validated the number. if (g_edit_entry->type() == typeid(double)) { - const auto meta = get_setting_metadata(g_edit_entry->m_config_file->m_config_file_stem_as_str, - g_edit_entry->m_definition.m_section, - g_edit_entry->m_definition.m_key); + const auto meta = resolved_metadata(g_edit_entry->m_config_file->m_config_file_stem_as_str, + g_edit_entry->m_definition.m_section, + g_edit_entry->m_definition.m_key); if (meta && (meta->has_min || meta->has_max || meta->has_step)) { double v = g_edit_entry->get_value_base(); @@ -1906,20 +1976,27 @@ namespace big::mod_settings // If the author declared this setting restart-required, flag/clear the restart. note_change_if_restart_required(g_edit_entry, g_edit_entry->get_serialized_value()); - // Reflect the committed value in the right-hand display in place. Do NOT rebuild the - // panel here: a rebuild frees and recreates every row, which snaps the visible page - // back to the top while the scrollbar keeps the scrolled position, so the rows and - // the scrollbar desync until the next manual scroll. Only this one value changed, so - // just update its label (the native number-box rows persist the same in-place way). + // Reflect the committed value in the right-hand display in place. Do NOT rebuild the panel here: a + // rebuild frees and recreates every row, which snaps the visible page back to the top while the + // scrollbar keeps the scrolled position, so the rows and the scrollbar desync until the next manual + // scroll. Only this one value changed, so just update its label (the native number-box rows persist the + // same in-place way). refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); + + // Other rows may still key off this value (e.g. an apply button's dynamic `disabled`), so a dynamic + // view re-evaluates its function rows shortly after (see g_dynamic_refresh_settle). + if (g_view_has_dynamic) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } } exit_edit_mode(); return true; } if (g_edit_cancel) { - // Restore the display to the unchanged value (the live caret label was transient); no - // rebuild, for the same scroll-preservation reason as the commit path above. + // Restore the display to the unchanged value (the live caret label was transient) no rebuild, for the same + // scroll-preservation reason as the commit path above. if (g_edit_entry) { refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); @@ -1930,8 +2007,8 @@ namespace big::mod_settings return false; } - // Live-updates the edited value display (right column) with a movable, blinking caret. Called - // from Update while editing is active; g_edit_component is the row's value component. + // Live-updates the edited value display (right column) with a movable, blinking caret. Called from Update while + // editing is active g_edit_component is the row's value component. static void update_edit_label() { if (g_edit_component && g_set_label) @@ -1948,16 +2025,32 @@ namespace big::mod_settings return big::string::to_lower(key) == "enabled"; } - // True when the options screen was opened during gameplay (a save is loaded), false when opened - // from the main menu. Captured from the MiscSettingsScreen constructor's "opened from" argument - // (see hook_MiscSettingsScreen_ctor); used to grey out context-restricted setting rows. + // True when the options screen was opened during gameplay (a save is loaded), false when opened from the main menu. + // Captured from the MiscSettingsScreen constructor's "opened from" argument (see hook_MiscSettingsScreen_ctor). + // Used to grey out context-restricted setting rows. static bool g_opened_in_game = false; - // The MiscSettingsScreen ctor's "opened from" argument is the opening screen (sgg::MenuScreen*): - // a MainMenuScreen when opened from the main menu, a PauseScreen when opened in-game (the only two - // call sites in the engine). GameScreen::GetType (virtual, vtable slot 10 - a `mov eax,imm; ret` - // stub, so calling it is side-effect-free and ASLR-independent) returns the screen's ScreenType; - // Pause identifies the in-game opener. + + // True while a native options screen is open (set in the ctor,. Cleared when it actually closes in ExitScreen). + // Combined with g_opened_in_game it gates on_change callbacks so they fire only for a setting changed through the + // in-game options menu - never from the main menu, and never from a mod's own config write while no in-game options + // screen is open (which avoids a stale g_opened_in_game firing a callback in the main menu, and avoids + // double-applying a mod's own UI writes). + static bool g_options_screen_open = false; + + // True while a setting change should notify its mod through an on_change callback: an options screen is currently + // open AND it was opened in-game (a save is loaded). This gates on_change so a callback fires only for an edit made + // through the in-game options menu that can be applied to the live run - never from the main menu, and never from a + // mod's own config write outside the menu. + bool on_change_callbacks_enabled() + { + return g_options_screen_open && g_opened_in_game; + } + + // The MiscSettingsScreen ctor's "opened from" argument is the opening screen (sgg::MenuScreen*): a MainMenuScreen + // when opened from the main menu, a PauseScreen when opened in-game (the only two call sites in the engine). + // GameScreen::GetType (virtual, vtable slot 10 - a `mov eax,imm ret` stub, so calling. It is side-effect-free and + // ASLR-independent) returns the screen's ScreenType. Pause identifies the in-game opener. static constexpr std::size_t game_screen_get_type_vtable_slot = 10; static constexpr int screen_type_pause = 0x10'00'03; // sgg::ScreenType::Pause @@ -1972,9 +2065,8 @@ namespace big::mod_settings return get_type(opened_from) == screen_type_pause; } - // The context in which a setting may actually be changed. Authors declare it, but the master - // "enabled" toggle and any restart_required setting are forced to main_menu because neither can - // take effect on the live save. + // The context in which a setting may actually be changed. Authors declare it, but the master "enabled" toggle and + // any restart_required setting are. Forced to main_menu because neither can take effect on the live save. static editable_context effective_editable_context(const std::optional& meta, bool is_enabled_toggle) { if (is_enabled_toggle) @@ -1988,20 +2080,20 @@ namespace big::mod_settings return meta ? meta->context : editable_context::any; } - // True when a setting cannot be changed in the current screen context (main-menu vs in-game), so - // its row is shown read-only with an explanatory note instead of an editable widget. + // True when a setting cannot be changed in the current screen context (main-menu vs in-game), so its row is shown + // read-only with an explanatory note instead of an editable widget. static bool is_context_restricted(editable_context ctx) { switch (ctx) { - case editable_context::main_menu: return g_opened_in_game; // main-menu-only, greyed while in a save - case editable_context::in_save: return !g_opened_in_game; // in-save-only, greyed at the main menu - default: return false; // any + case editable_context::main_menu: return g_opened_in_game; // main-menu-only, greyed while in a save. + case editable_context::in_save: return !g_opened_in_game; // in-save-only, greyed at the main menu. + default: return false; // any. } } - // The note shown in the description box for a row that is read-only because of its editable - // context. Empty for `any` (never restricted). + // The note shown in the description box for a row that is read-only because of its editable context. Empty for + // `any` (never restricted). static std::string context_note(editable_context ctx) { switch (ctx) @@ -2012,15 +2104,14 @@ namespace big::mod_settings } } - // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. - // Leaf entries render as setting rows (bool -> toggle, enum/bounded number -> num box, else a - // freetext value); each direct child section renders as a group row that drills into it. At the - // root section a boolean "enabled" entry (if present) is pinned to the top; when it is off, every - // other row is greyed out and made non-interactable. + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render + // as setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). Each direct child + // section renders as a group row that drills into it. At the root section a boolean "enabled" entry (if present) is + // pinned to the top when it is off, every other row is greyed out and made non-interactable. static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem, const std::string& section) { - // A menu item is either a leaf setting directly in `section`, or a direct child group (a - // nested sub-section such as "config.biome_pool" while viewing "config"). + // A menu item is either a leaf setting directly in `section`, or a direct child group (a nested sub-section + // such as "config.biome_pool" while viewing "config"). struct panel_item { bool is_group = false; @@ -2031,10 +2122,12 @@ namespace big::mod_settings double order = 0.0; int appearance = INT_MAX; // config.lua source rank (fallback order) bool is_enabled = false; // the mod's master "enabled" toggle (root section only) + bool is_action = false; // a config.lua action button (runs a Lua callback, no config value) + action_info action; // valid when is_action }; std::vector items; - std::map groups; // child section path -> group item (keeps its min appearance) + std::map groups; // child section path -> group item (keeps its min appearance). toml_v2::config_file::config_entry_base* enabled_entry = nullptr; const std::string section_prefix = section + "."; @@ -2051,8 +2144,8 @@ namespace big::mod_settings continue; } - // The mod's master switch lives in the root section; track it whatever section is - // being shown, so nested rows are greyed when the mod is disabled. + // The mod's master switch lives in the root section track it whatever section is being shown, so nested + // rows are greyed when the mod is disabled. if (!enabled_entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) { enabled_entry = entry.get(); @@ -2064,7 +2157,7 @@ namespace big::mod_settings it.key = key.m_key; it.entry = entry.get(); it.appearance = get_setting_appearance_order(stem, key.m_section, key.m_key); - if (const auto meta = get_setting_metadata(stem, key.m_section, key.m_key); meta && meta->has_order) + if (const auto meta = resolved_metadata(stem, key.m_section, key.m_key); meta && meta->has_order) { it.has_order = true; it.order = meta->order; @@ -2073,9 +2166,8 @@ namespace big::mod_settings } else if (key.m_section.rfind(section_prefix, 0) == 0) { - // A descendant section: the direct child under `section` is the first path segment - // after the prefix. Collapse its whole subtree into one group row, ranked by its - // earliest-defined descendant. + // A descendant section: the direct child under `section` is the first path segment after the + // prefix. Collapse its whole subtree into one group row, ranked by its earliest-defined descendant. const std::string rest = key.m_section.substr(section_prefix.size()); const std::string child = rest.substr(0, rest.find('.')); const std::string child_path = section_prefix + child; @@ -2088,7 +2180,7 @@ namespace big::mod_settings g.key = child; g.child_section = child_path; g.appearance = app; - if (const auto meta = get_setting_metadata(stem, section, child); meta && meta->has_order) + if (const auto meta = resolved_metadata(stem, section, child); meta && meta->has_order) { g.has_order = true; g.order = meta->order; @@ -2108,6 +2200,19 @@ namespace big::mod_settings items.push_back(std::move(kv.second)); } + // Action buttons declared directly in this section (config.lua `action` entries). They carry no config value, + // so they are collected separately and sorted in with the settings by `order`. + for (auto& a : get_actions(stem, section)) + { + panel_item it; + it.is_action = true; + it.key = a.key; + it.has_order = a.has_order; + it.order = a.order; + it.action = std::move(a); + items.push_back(std::move(it)); + } + const bool mod_enabled = !enabled_entry || enabled_entry->get_value_base(); if (section == root_section && enabled_entry) { @@ -2120,9 +2225,9 @@ namespace big::mod_settings } } - // Row order: the master "enabled" toggle is pinned to the top; then rows with an author - // `order` (ascending); then the rest. Ties and absent order fall back to config.lua source - // order (a group's rank is its earliest-defined descendant's). + // Row order: the master "enabled" toggle is pinned to the top then rows with an author `order` (ascending) then + // The rest. Ties and absent order fall back to config.lua source order (a group's rank is its earliest-defined + // descendant's). std::stable_sort(items.begin(), items.end(), [](const panel_item& a, const panel_item& b) @@ -2155,10 +2260,43 @@ namespace big::mod_settings const bool is_enabled_row = it.is_enabled; const bool disabled = !is_enabled_row && !mod_enabled; + // An action button runs a Lua callback (config.lua `action`). It edits no config value. It is greyed and + // inert when the mod is disabled, when the author marked it `disabled`, or when its editable_context does + // not match the current screen (main-menu vs in-save). + if (it.is_action) + { + if (it.action.has_dynamic) + { + g_view_has_dynamic = true; + } + const bool ctx_blocked = is_context_restricted(it.action.context); + const bool act_disabled = disabled || it.action.disabled || ctx_blocked; + const std::string name = resolve_localized(it.action.name); + const std::string label = escape_markup(name.empty() ? key_to_display(it.key) : name); + if (auto* row = make_button_row(screen, label.c_str(), act_disabled, /*block_input*/ act_disabled)) + { + // A greyed action button is fully inert: not hoverable, not selectable, not clickable (unlike a + // context-restricted setting, which stays focusable to show its note). Clearing mSelectable makes. + // MenuScreen::SetMouseOver. Skip it entirely, so it never highlights or takes the selection + // m_can_be_focused = false blocks focus too. + if (act_disabled) + { + row->m_can_be_focused = false; + *reinterpret_cast(reinterpret_cast(row) + sgg::gui_component_button_selectable_offset) = false; + } + PanelRow pr{row, RowKind::action, stem, it.key}; + pr.disabled = act_disabled; + pr.target_section = it.action.section; // the section the action's callback lives in. + pr.description = ctx_blocked ? context_note(it.action.context) : resolve_localized(it.action.description); + g_rows.push_back(std::move(pr)); + } + continue; + } + // A nested group drills into its child section when clicked/activated. if (it.is_group) { - const auto gmeta = get_setting_metadata(stem, section, it.key); + const auto gmeta = resolved_metadata(stem, section, it.key); if (gmeta && gmeta->hidden) { continue; @@ -2183,34 +2321,41 @@ namespace big::mod_settings auto* entry = it.entry; // Author metadata (if any) can rename the row, hide it, and (later) pick its widget. - const auto meta = get_setting_metadata(stem, entry->m_definition.m_section, entry->m_definition.m_key); + const auto meta = resolved_metadata(stem, entry->m_definition.m_section, entry->m_definition.m_key); if (meta && meta->hidden) { continue; } - const std::string mname = meta ? resolve_localized(meta->name) : std::string{}; - const std::string label = escape_markup(!mname.empty() ? mname : key_to_display(key)); - // An enum (metadata `values`) renders as a native number box cycling its label list; a - // numeric setting with author-declared min AND max renders as a native number box over - // its range (like the FPS-limit option) UNLESS the author set `freetext` (e.g. for a very - // large range better typed than stepped); other numbers stay freetext-editable with a - // plain right-column value label. + // An author may mark a setting `disabled` (statically or via a dynamic function): the row stays visible but + // is shown read-only and greyed (e.g. a cap that only applies while its parent fix is on). Rendered through + // the same greyed read-only text+value path as a context-restricted row, which reads as clearly greyed (a + // disabled slider/toggle keeps its bright graphic and does not). Distinct from the mod-disabled greying + // (whole panel off), which keeps the native widgets. + const bool author_disabled = meta && meta->disabled; + const std::string mname = meta ? resolve_localized(meta->name) : std::string{}; + const std::string label = escape_markup(!mname.empty() ? mname : key_to_display(key)); + + // An enum (metadata `values`) renders as a native number box cycling its label list. A numeric setting with + // author-declared min AND max renders as a native number box over its range (like the FPS-limit option) + // UNLESS the author set `freetext` (e.g. for a very large range better typed than stepped) other numbers + // stay freetext-editable with a plain right-column value label. const bool is_number = entry->type() == typeid(double); const bool is_enum = meta && !meta->values.empty(); const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max && !meta->freetext; const double step = (meta && meta->has_step) ? meta->step : 1.0; - // Enum option lists (serialized values + parallel labels), resolved once so the widget and - // the PanelRow share them. The current value maps to its index, defaulting to 0. + // Enum option lists (serialized values + parallel labels), resolved once so the widget and the PanelRow + // share them. The current value maps to its index, defaulting to 0. std::vector enum_values; std::vector enum_labels; int enum_index = 0; if (is_enum) { enum_values = meta->values; - // Labels parallel the values when the author supplied a full set (each resolved to the - // current language); otherwise the raw values double as their own labels. + + // Labels parallel the values when the author supplied a full set (each resolved to the current + // language) otherwise the raw values double as their own labels. if (meta->labels.size() == enum_values.size()) { for (const auto& lbl : meta->labels) @@ -2237,13 +2382,13 @@ namespace big::mod_settings GUIComponent* value = nullptr; bool built_slider = false; - // A setting whose editable context does not match the current screen (main-menu vs - // in-game) is shown read-only: its current value in a greyed key+value row that still - // takes focus, so the description box can explain where to change it. Edits are blocked - // by pr.disabled in the row handlers. Skipped when the mod is disabled, whose own greying - // already covers every row. + // A setting that is unavailable in the current context (editable_context mismatch) or that the author + // marked `disabled` is shown read-only: its current value in a greyed key+value row that still takes focus, + // so the description box can explain why. Edits are blocked by pr.disabled in the row handlers. Skipped + // when the whole mod is disabled, whose own greying already covers every row with the native widgets. const editable_context ctx = effective_editable_context(meta, is_enabled_row); - if (!disabled && is_context_restricted(ctx)) + const bool context_blocked = is_context_restricted(ctx); + if (!disabled && (context_blocked || author_disabled)) { std::string vtext; if (entry->type() == typeid(bool)) @@ -2269,7 +2414,10 @@ namespace big::mod_settings pr.disabled = true; // blocks every edit path (click / slider / num-box) via the row handlers pr.is_enabled_toggle = is_enabled_row; pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); - pr.description = context_note(ctx); + + // Context mismatch shows where to change it an author-disabled row shows its normal description + // (disabled_description support is a separate task). + pr.description = context_blocked ? context_note(ctx) : (meta ? resolve_localized(meta->description) : std::string{}); g_rows.push_back(pr); } continue; @@ -2285,8 +2433,8 @@ namespace big::mod_settings } else if (is_stepper) { - // Bounded number: a slider (drag bar) like the audio-volume rows, snapped to step. Fall - // back to a number-box stepper if the slider cannot be built on this game build. + // Bounded number: a slider (drag bar) like the audio-volume rows, snapped to step. Fall back to a + // number-box stepper if the slider cannot be built on this game build. row = make_slider_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), meta->show_as_percentage, meta->is_percentage, disabled); if (row) { @@ -2314,8 +2462,9 @@ namespace big::mod_settings pr.disabled = disabled; pr.is_enabled_toggle = is_enabled_row; pr.value_component = value; - // Prefer the author's metadata description (resolved to the current language); fall back - // to the .cfg comment text. + + // Prefer the author's metadata description (resolved to the current language), else fall back to the + // .cfg comment text. const std::string mdesc = meta ? resolve_localized(meta->description) : std::string{}; pr.description = !mdesc.empty() ? mdesc : entry->m_description.m_description; @@ -2341,13 +2490,13 @@ namespace big::mod_settings } } - // Matches the native category-switch transition: the incoming page fades in and there is no - // fade-out crossover. Native UpdateScrollState sets each on-page row's mFadeTarget to 1 and each - // off-page row's to 0, and GUIComponent::Update (driven by MenuScreen::Update, which the original - // runs before this) eases mFadeOpacity toward the target at dt * mFadeSpeed - so on-page rows are - // left entirely to the native ease. We only force off-page rows fully transparent so a row leaving - // the page vanishes at once instead of fading out on top of the incoming page. Rows are in - // m_options / g_rows order, so row i is on the current page when start <= i < start + rows_per_page. + // Matches the native category-switch transition: the incoming page fades in and there is no fade-out crossover. + // Native UpdateScrollState sets each on-page row's mFadeTarget to 1 and each off-page row's to 0, and + // GUIComponent::Update (driven by MenuScreen::Update, which the original runs before this) eases mFadeOpacity + // toward the target at dt * mFadeSpeed - so on-page rows are left entirely to the native ease. We only force + // off-page rows fully transparent so a row leaving the page vanishes at once instead of fading out on top of the + // incoming page. Rows are in m_options / g_rows order, so row i is on the current page when start <= i < start + + // rows_per_page. static void sync_scroll_fade(MiscSettingsScreen* screen) { const std::size_t first = screen->m_page_start_index; @@ -2363,9 +2512,8 @@ namespace big::mod_settings } } - // Value displays are not in mOptions, so the engine's scroll pass does not lay them out. - // Mirror each value component onto its key row's current position and fade so the right - // column tracks scrolling and fade-in/out. + // Value displays are not in mOptions, so the engine's scroll pass does not lay them out. Mirror each value + // component onto its key row's current position and fade so the right column tracks scrolling and fade-in/out. static void sync_value_columns() { for (const auto& row : g_rows) @@ -2384,16 +2532,56 @@ namespace big::mod_settings } } - // The component the user is currently on: the mouse-over one (mouse) takes priority, else the - // selected one (keyboard/controller). These are MenuScreen fields (flat struct view). + // Gives action-button rows extra vertical room. The native UpdateScrollState lays every on-page row on a uniform + // 45px grid, but the Button_Secondary box is taller, so consecutive buttons would overlap. Walking the on-page rows + // top to bottom, each button is nudged down by button_extra_lead. Every row below it is shifted by + // button_extra_lead + button_extra_trail. Runs from the UpdateScrollState detour (right after the grid layout it + // undoes, and before the row hit-test in the same Update) so the hover/click rects stay aligned with the drawn + // buttons. + static void sync_button_spacing(MiscSettingsScreen* screen) + { + const std::size_t first = screen->m_page_start_index; + const std::size_t last = first + rows_per_page; + float extra = 0.0f; + for (std::size_t i = first; i < last && i < g_rows.size(); ++i) + { + GUIComponent* c = g_rows[i].component; + if (!c) + { + continue; + } + if (g_rows[i].kind == RowKind::action) + { + c->m_location_y += extra + button_extra_lead; + extra += button_extra_lead + button_extra_trail; + + // The mouse hover/click hit-test reads the button's child label location, not the button's own + // (GUIComponentButton::GetArea returns the label's text area), so move the label to the shifted button + // position or the hit rect stays on the unshifted grid slot. Absolute assignment (label follows the + // button) avoids drift: UpdateScrollState resets both to the grid each frame via the button's. + // SetLocation before this runs. + if (auto* label = *reinterpret_cast(reinterpret_cast(c) + button_label_offset)) + { + label->m_location_y = c->m_location_y; + } + } + else + { + c->m_location_y += extra; + } + } + } + + // The component the user is currently on: the mouse-over one (mouse) takes priority, else the selected one + // (keyboard/controller). These are MenuScreen fields (flat struct view). static GUIComponent* active_row_component(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); return menu->m_mouse_over_component ? menu->m_mouse_over_component : menu->m_selected_component; } - // Finds the PanelRow whose left-column component is `comp`, or nullptr. Valid until the next - // panel rebuild (deferred to Update), so callers within a single input/update pass may keep it. + // Finds the PanelRow whose left-column component is `comp`, or nullptr. Valid until the next panel rebuild + // (deferred to Update), so callers within a single input/update pass may keep it. static PanelRow* find_row(GUIComponent* comp) { if (!comp) @@ -2410,14 +2598,28 @@ namespace big::mod_settings return nullptr; } - // The component whose description was last written to the description box, so the box is only - // updated when the highlighted row changes (not every frame). Reset when the panel rebuilds. + // True while the user is still interacting with one of our rows: the entered component (keyboard or controller + // adjusting a slider/enum) or the moused-over component (mouse hovering or dragging one). The numeric-change + // dynamic refresh holds its rebuild until this is false, so the rebuild never frees a row that is being adjusted + // (which would drop keyboard focus or interrupt a mouse drag). + static bool interacting_with_row(MiscSettingsScreen* screen) + { + if (screen->m_component_focused && find_row(screen->m_component_focused)) + { + return true; + } + auto* menu = reinterpret_cast(screen); + return menu->m_mouse_over_component && find_row(menu->m_mouse_over_component); + } + + // The component whose description was last written to the description box. The box is only updated when the + // highlighted row changes (not every frame). Reset when the panel rebuilds. static GUIComponent* g_last_description_component = nullptr; // Shows the highlighted row's author description in the screen's native description box - // (MiscSettingsScreen::mDescriptionBox @ 0x460). The highlighted component is the mouse-over - // one (mouse) or the selected one (keyboard/controller); if it is one of our rows, its - // description is shown as raw text, otherwise the box is cleared. + // (MiscSettingsScreen::mDescriptionBox @ 0x460). The highlighted component is the mouse-over one (mouse) or the + // selected one (keyboard/controller). If it is one of our rows, its description is shown as raw text. Otherwise the + // box is cleared. static void sync_description_box(MiscSettingsScreen* screen) { if (!g_show_text || !screen->m_description_box) @@ -2440,35 +2642,35 @@ namespace big::mod_settings if (active != g_last_description_component) { g_last_description_component = active; + // Escape markup so paths/brackets in the description render verbatim (see escape_markup). const std::string shown = show ? escape_markup(*description) : std::string{}; g_show_text(box, shown.c_str()); - // ShowText only marks the lines dirty; the layout (and text height, which the box's - // justification uses to place the text) is otherwise recomputed lazily at draw time, - // so the first visible frame would render at a stale position and visibly jump. Force - // the line rebuild now so the first shown frame is already laid out. + // ShowText only marks the lines dirty. The layout (and text height, which the box's justification uses to + // place the text) is otherwise recomputed lazily at draw time, so the first visible frame would render at a + // stale position and visibly jump. Force the line rebuild now so the first shown frame is already laid out. if (show && g_get_lines) { g_get_lines(box); } } - // Re-apply the fade every frame: the native Update runs before this and re-hides the box on - // the Mods tab (it does not use mDescriptionBox here), so a one-time set would fade back out. + // Re-apply the fade every frame: the native Update runs before this and re-hides. The box on the Mods tab (it + // does not use mDescriptionBox here), so a one-time set would fade back out. box->m_fade_opacity = show ? 1.0f : 0.0f; box->m_fade_target = show ? 1.0f : 0.0f; } - // Last label we wrote to each bottom-prompt button, so SetDisplayName is only called when the - // label actually changes (avoids re-laying out the text every frame). Cleared when we leave the - // Mods tab so the native labels take back over and re-entering re-applies ours. + // Last label we wrote to each bottom-prompt button, so SetDisplayName is only called when the label actually + // changes (avoids re-laying out the text every frame). Cleared when we leave the Mods tab so the native labels take + // back over and re-entering re-applies ours. static std::string g_prompt_confirm_label; static std::string g_prompt_cancel_label; - // Sets a bottom-prompt button's label (GUIComponentButton::SetDisplayName) only when it changes - // from what we last set. The key glyph is driven by the button's bound control, not the label, so - // it stays correct (Enter for Confirm, Esc for Cancel) regardless of the text. + // Sets a bottom-prompt button's label (GUIComponentButton::SetDisplayName) only when it changes from what we last + // set. The key glyph is driven by the button's bound control, not the label, so it stays correct (Enter for + // Confirm, Esc for Cancel) regardless of the text. static void set_prompt_label(GUIComponent* button, std::string& cache, const char* text) { if (!button || !g_set_label || cache == text) @@ -2479,10 +2681,10 @@ namespace big::mod_settings g_set_label(button, text); } - // Retunes the options screen's bottom button prompts for the Mods tab per context, and hides the - // native Reset prompt where it must not apply. Called every frame from the Update hook (after the - // original, which sets the native prompts on focus/hover/category events). Off the Mods tab it - // only clears our caches and leaves the native prompts untouched. + // Retunes the options screen's bottom button prompts for the Mods tab per context, and hides the native. Reset + // prompt where it must not apply. Called every frame from the Update hook (after the original, which sets the + // native prompts on focus/hover/category events). Off the Mods tab it only clears our caches and leaves the native + // prompts untouched. static void sync_prompts(MiscSettingsScreen* screen, bool on_mods_tab) { if (!on_mods_tab) @@ -2494,16 +2696,15 @@ namespace big::mod_settings auto* menu = reinterpret_cast(screen); - // The native prompt strings embed a glyph token that the text box expands to the device- - // appropriate key icon: "{CN}" = the Cancel control (Esc / B), "{SL}" = the Select/Confirm - // control (Enter / A). We prepend the same token to our custom labels so the icon is kept - // (a raw string with no token renders text only). Labels are upper-case to match the game. - // Cancel (Esc): "CANCEL" while editing a field; "BACK" inside a mod's settings (Esc returns to - // the mod list, see the ExitScreen hook); "EXIT" at the mod list (closes the options screen). + // The native prompt strings embed a glyph token that the text box expands to the device- appropriate key icon:. + // "{CN}" = the Cancel control (Esc / B), "{SL}" = the Select/Confirm control (Enter / A). We prepend the same + // token to our custom labels so the icon is kept (a raw string with no token renders text only). Labels are + // upper-case to match the game Cancel (Esc): "CANCEL" while editing a field "BACK" inside a mod's settings (Esc + // returns to the mod list, see the ExitScreen hook) "EXIT" at the mod list (closes the options screen). const char* cancel = g_editing ? "{CN} CANCEL" : (g_view == View::mod_settings ? "{CN} BACK" : "{CN} EXIT"); set_prompt_label(menu->m_cancel_button, g_prompt_cancel_label, cancel); - // Confirm (Enter): "SUBMIT" while editing; otherwise a verb matching the highlighted row. + // Confirm (Enter): "SUBMIT" while editing otherwise a verb matching the highlighted row. std::string confirm; if (g_editing) { @@ -2513,8 +2714,8 @@ namespace big::mod_settings { if (row->disabled) { - // A greyed, non-interactable row (e.g. an opted-out mod) has no confirm action, so - // show no confirm prompt for it. + // A greyed, non-interactable row (e.g. an opted-out mod) has no confirm action, so show no confirm + // prompt for it. confirm.clear(); } else @@ -2550,10 +2751,10 @@ namespace big::mod_settings } } - // Drive the Confirm prompt's visibility ourselves: native only fades it in (OnOptionMouseOver) - // for its OWN option rows, which never fires for our custom rows. Show it with its glyph - // whenever we have a hint, hide it when we don't. mFadeOpacity is the field the draw gate - // reads; native Update rewrites mHidden each frame, so both are set here (after the original Update). + // Drive the Confirm prompt's visibility ourselves: native only fades it in (OnOptionMouseOver) for its OWN + // option rows, which never fires for our custom rows Show it with its glyph whenever we have a hint, hide it + // when we don't mFadeOpacity is the field the draw gate reads native Update rewrites mHidden each frame, so + // both are set here (after the original Update). if (menu->m_confirm_button) { if (confirm.empty()) @@ -2570,9 +2771,9 @@ namespace big::mod_settings } } - // Reset prompt: shown only inside a single mod's settings (resets that mod) and not while - // editing. It is hidden in the mod list/overview so users cannot reset every mod's config by - // accident (the RestoreDefaults hook also swallows the shortcut there). + // Reset prompt: shown only inside a single mod's settings (resets that mod) and not while editing. It is hidden + // in the mod list/overview so users cannot reset every mod's config by accident (the. RestoreDefaults hook also + // swallows the shortcut there). if (screen->m_defaults_button) { const bool show_reset = (g_view == View::mod_settings) && !g_editing; @@ -2580,22 +2781,21 @@ namespace big::mod_settings } } - // Focuses the first selectable row so the controller/keyboard cursor lands on it, as a native - // category does when shown. The engine's DoShowCategory teleports the free-form cursor onto - // mOptions[0] and clears mCategoryFocused (switching from tab to option navigation) only when the - // option list is already populated at that point; our rows are appended afterwards, so it is - // skipped - leaving the screen in tab-navigation mode, which is why the stick never reaches the - // rows (no highlight, sliders ignore left/right) until the tab is selected a second time. Mouse - // mode is left untouched (the mouse drives hover itself; teleporting would yank the pointer). - // Drops the controller/keyboard cursor onto a specific row so the next Update focuses it (green + - // stick input). No-op in mouse mode (the mouse drives hover). The row must be selectable. + // Focuses the first selectable row so the controller/keyboard cursor lands on it, as a native category does when + // shown. The engine's DoShowCategory teleports the free-form cursor onto mOptions[0] and clears mCategoryFocused + // (switching from tab to option navigation) only when the option list is already populated at that point our rows + // are appended afterwards, so it is skipped - leaving the screen in tab-navigation mode, which is why the stick + // never reaches the rows (no highlight, sliders ignore left/right) until the tab is selected a second time. Mouse + // mode is left untouched (the mouse drives hover itself, teleporting would yank the pointer). Drops the + // controller/keyboard cursor onto a specific row so the next Update focuses it (green + stick input). No-op in + // mouse mode (the mouse drives hover). The row must be selectable. static void focus_row(MiscSettingsScreen* screen, GUIComponent* component) { if (!g_teleport_cursor || (g_use_mouse && *g_use_mouse) || !component) { return; } - g_teleport_cursor(screen, component); // drop the cursor on the row; next Update focuses it + g_teleport_cursor(screen, component); // drop the cursor on the row, next Update focuses it screen->m_category_focused = false; // hand navigation from the tab bar to the option rows } @@ -2616,10 +2816,9 @@ namespace big::mod_settings } } - // The row a pending back-navigation should re-focus: the mod_entry row of the mod that was open - // (focus_stem set), or the group row that drills into the section that was open (focus_section set). - // Exactly one of the two fields is set per restore. Returns nullptr if that row is not in the freshly - // built view (e.g. it was removed since). + // The row a pending back-navigation should re-focus: the mod_entry row of the mod that was open (focus_stem set), + // or the group row that drills into the section that was open (focus_section set). Exactly one of the two fields is + // set per restore. Returns nullptr if that row is not in the freshly built view (e.g. it was removed since). static GUIComponent* restore_target_row(const NavRestore& r) { for (const auto& row : g_rows) @@ -2638,8 +2837,8 @@ namespace big::mod_settings return nullptr; } - // Queues a one-level back navigation inside a mod's settings: a nested group returns to its parent - // section, and the root returns to the mod list. Applied next Update via apply_nav. + // Queues a one-level back navigation inside a mod's settings: a nested group returns to its parent section, and the + // root returns to the mod list. Applied next Update via apply_nav. static void request_back_nav() { const auto dot = g_view_section.rfind('.'); @@ -2658,9 +2857,8 @@ namespace big::mod_settings g_nav_pending = true; } - // True if a remappable control (e.g. Back/Cancel = controller B + keyboard Esc, or Select = - // controller A + Enter) was pressed this frame. Bit 0x4 of the control's state is "was pressed" - // (edge, not held). + // True if a remappable control (e.g. Back/Cancel = controller B + keyboard Esc, or Select = controller A + Enter) + // was pressed this frame Bit 0x4 of the control's state is "was pressed" (edge, not held). static bool control_pressed(void* input, const void* control) { if (!input || !g_input_get_state || !control) @@ -2672,21 +2870,38 @@ namespace big::mod_settings static void build_panel(MiscSettingsScreen* screen, bool instant = false) { - // A rebuild frees and recreates the row components, so the cached highlighted-row pointer - // is stale; force the description box to refresh next frame. + // A rebuild frees and recreates the row components, so the cached highlighted-row pointer is stale force the + // description box to refresh next frame. g_last_description_component = nullptr; - // Preserve the current scroll offset across an in-place refresh (same view/mod, e.g. - // after committing a setting edit or toggling "enabled") so confirming a setting on a - // lower page does not jump back to the top. A real view change (instant == false) - // starts at the top. + // Preserve the current scroll offset across an in-place refresh (same view/mod, e.g. after committing a setting + // edit or toggling "enabled") so confirming a setting on a lower page does not jump back to the top. A real + // view change (instant == false) starts at the top. const std::uint32_t prev_start = screen->m_page_start_index; + // On an instant (same-view) rebuild the highlighted row is freed and recreated, so remember it to put the + // keyboard/controller cursor back afterwards (mouse uses hover, so this is gated to non-mouse mode). Only + // setting/action rows (those with a key) are tracked. + RowKind cursor_kind = RowKind::mod_entry; + std::string cursor_key; + bool had_cursor = false; + if (instant && !(g_use_mouse && *g_use_mouse)) + { + auto* menu = reinterpret_cast(screen); + GUIComponent* target = screen->m_component_focused ? screen->m_component_focused : menu->m_selected_component; + if (const PanelRow* fr = find_row(target); fr && !fr->setting_key.empty()) + { + cursor_kind = fr->kind; + cursor_key = fr->setting_key; + had_cursor = true; + } + } + // Remove any rows from a previous view/visit before building the new set. destroy_rows(screen); - // Resolve the blank graphic lazily: the string-intern table is not ready at hook - // registration time, so "Blank" only hashes correctly once the game is running. + // Resolve the blank graphic lazily: the string-intern table is not ready at hook registration time, so "Blank" + // only hashes correctly once the game is running. if (!g_blank_graphic && g_hash_lookup) { HashGuid res{}; @@ -2694,6 +2909,13 @@ namespace big::mod_settings g_blank_graphic = res.m_id; } + // Recomputed during the build: true if any row in the new view has a dynamic (Lua-function) field, so a bool + // toggle should trigger an in-place rebuild to re-evaluate it live. + g_view_has_dynamic = false; + + // This rebuild supersedes any pending numeric-change refresh, so cancel its debounce. + g_dynamic_refresh_settle = 0.0f; + if (g_view == View::mod_settings && !g_view_stem.empty()) { build_mod_settings(screen, g_view_stem, g_view_section.empty() ? root_section : g_view_section); @@ -2703,20 +2925,18 @@ namespace big::mod_settings build_mod_list(screen); } - // Let the engine position, paginate and drive the scrollbar/arrows for the rows. - // - // Backing out to a parent view (the mod list, or a parent section) is a real view change (not - // instant), which would otherwise snap to the top. If a restore is pending from the back-nav, - // restore that view's saved scroll offset so the user lands where they were. + // Let the engine position, paginate and drive the scrollbar/arrows for the rows. Backing out to a parent view + // (the mod list, or a parent section) is a real view change (not instant), which would otherwise snap to the + // top. If a restore is pending from the back-nav, restore that view's saved scroll offset so the user lands + // where they were. const bool restoring = !instant && g_has_pending_restore; std::uint32_t start = 0; if (instant || restoring) { - // Restore the exact offset the view had. Only clamp when it now points past the last row - // (the row count shrank, e.g. a row became hidden), and then to the first index of the - // last page - so a partial final page (fewer than rows_per_page rows) keeps its own offset - // instead of being pulled up into a full page of rows. + // Restore the exact offset the view had. Only clamp when it now points past the last row (the row count + // shrank, e.g. a row became hidden), and then to the first index of the last page - so a partial final page + // (fewer than rows_per_page rows) keeps its own offset instead of being pulled up into a full page of rows. const std::uint32_t row_count = static_cast(g_rows.size()); const std::uint32_t last_page_start = row_count > 0 ? ((row_count - 1) / rows_per_page) * rows_per_page : 0; const std::uint32_t desired = instant ? prev_start : g_pending_restore.scroll_index; @@ -2731,8 +2951,8 @@ namespace big::mod_settings if (instant) { - // In-place refresh (e.g. toggling the mod's "enabled" switch, which only changes greying): - // snap each row straight to its final visibility so the panel does not flash a fade. + // In-place refresh (e.g. toggling the mod's "enabled" switch, which only changes greying): snap each row + // straight to its final visibility so the panel does not flash a fade. for (const auto& row : g_rows) { if (row.component) @@ -2741,19 +2961,19 @@ namespace big::mod_settings } } } - // A view change leaves the freshly built rows at mFadeOpacity 0 (finalize_row); the native - // ease (GUIComponent::Update) then fades the on-page rows in toward mFadeTarget == 1, matching - // the game's own category-switch transition. Off-page rows are held transparent in - // sync_scroll_fade. - // Value displays are not laid out by the scroll pass; place them on their key rows now. + // A view change leaves the freshly built rows at mFadeOpacity 0 (finalize_row). The native ease + // (GUIComponent::Update) then fades the on-page rows in toward mFadeTarget == 1, matching the game's own + // category-switch transition. Off-page rows are held transparent in sync_scroll_fade. Value displays are not + // laid out by the scroll pass place them on their key rows now. The action-button vertical spacing is applied + // in the UpdateScrollState detour (which the direct g_update_scroll call above routes through), so the key rows + // are already shifted here. sync_value_columns(); - // On a real view change (tab entry, drilling in, going back), drop the cursor on the first row - // so it highlights immediately like a native category. Skipped on in-place refreshes so - // committing an edit or toggling "enabled" does not yank focus back to the top. When backing - // out, focus the row the user drilled through (the mod in the list, or the group in its parent - // section) rather than the first row. + // On a real view change (tab entry, drilling in, going back), drop the cursor on the first row so it highlights + // immediately like a native category. Skipped on in-place refreshes so committing an edit or toggling "enabled" + // does not yank focus back to the top. When backing out, focus the row the user drilled through (the mod in the + // list, or the group in its parent section) rather than the first row. if (!instant) { GUIComponent* restore_focus = restoring ? restore_target_row(g_pending_restore) : nullptr; @@ -2766,6 +2986,20 @@ namespace big::mod_settings focus_first_row(screen); } } + else if (had_cursor) + { + // Put the keyboard/controller cursor back on the equivalent new row so an instant rebuild (a toggle, an + // action, or the deferred numeric refresh) does not drop it. No-op for mouse. + for (const auto& row : g_rows) + { + if (row.component && row.kind == cursor_kind && row.setting_key == cursor_key && !row.disabled + && row.component->m_is_useable && !row.component->m_hidden) + { + focus_row(screen, row.component); + break; + } + } + } if (restoring) { @@ -2773,25 +3007,23 @@ namespace big::mod_settings } } - // Applies a queued navigation (mod list <-> a mod's settings) by rebuilding the panel. - // Called from the Update hook, i.e. outside click/input iteration, where mutating the - // component vectors is safe. A rebuild that stays on the same view/mod (e.g. after - // toggling "enabled") is applied instantly to avoid a fade flash; a real view change - // keeps the fade-in. + // Applies a queued navigation (mod list <-> a mod's settings) by rebuilding the panel. Called from the Update hook, + // i.e. outside click/input iteration, where mutating the component vectors is safe. A rebuild that stays on the + // same view/mod (e.g. after toggling "enabled") is applied instantly to avoid a fade flash. A real view change + // keeps the fade-in transition. static void apply_nav(MiscSettingsScreen* screen) { - // A Reset forces a top (non-instant) rebuild even though the view is unchanged, so the - // restored rows and the scrollbar stay in sync - an in-place rebuild that preserves a - // scrolled position would leave the stale page-1 rows visible (see the scroll-model notes). + // A Reset forces a top (non-instant) rebuild even though the view is unchanged, so the restored rows and the + // scrollbar stay in sync - an in-place rebuild that preserves a scrolled position would leave the stale page-1 + // rows visible (see the scroll-model notes). const bool instant = !g_nav_reset_to_top && (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); g_nav_reset_to_top = false; - // Maintain the restore stack. A drill-in step (the mod list into a mod, or a section into a - // deeper child section) pushes the parent's scroll offset plus the identity of the row being - // drilled through; a back step (a mod out to the list, or a child section out to its parent) - // pops that entry for build_panel to restore. g_view is still the old (parent) view here, so - // m_page_start_index is the parent's own scroll offset. A same-view rebuild (instant: Reset or - // an "enabled" toggle) is neither, so it leaves the stack untouched. + // Maintain the restore stack. A drill-in step (the mod list into a mod, or a section into a deeper child + // section) pushes the parent's scroll offset plus the identity of the row being drilled through A back step (a + // mod out to the list, or a child section out to its parent) pops that entry for build_panel to restore g_view + // is still the parent view here, so m_page_start_index is the parent's own scroll offset. A same-view rebuild + // (instant:. Reset or an "enabled" toggle) is neither, so it leaves the stack untouched. const bool drilling_in = (g_view == View::mod_list && g_pending_view == View::mod_settings) || (g_view == View::mod_settings && g_pending_view == View::mod_settings && g_pending_section.rfind(g_view_section + ".", 0) == 0); @@ -2826,11 +3058,11 @@ namespace big::mod_settings build_panel(screen, instant); } - // The serialized default of a config entry, read from the entry itself via the public - // write_description (whose last output line is "# Default value: "). Works for any entry - // regardless of who bound it, so it recovers defaults for Chalk-bound mods, which never went through - // rom.mod_settings.load and so have no captured default in get_setting_default. The serialized form - // uses the same converter as get_serialized_value, so it round-trips through set_serialized_value. + // The serialized default of a config entry, read from the entry itself via the public write_description (whose last + // output line is "#. Default value: "). Works for any entry regardless of who bound it, so it recovers + // defaults for Chalk-bound mods, which never went through rom.mod_settings.load and so have no captured default in + // get_setting_default. The serialized form uses the same converter as get_serialized_value, so it round-trips + // through set_serialized_value. static std::optional entry_default_serialized(toml_v2::config_file::config_entry_base* entry) { if (!entry) @@ -2849,12 +3081,11 @@ namespace big::mod_settings return text.substr(pos + marker.size()); } - // Restores the current mod's config entries (g_view_stem) to their defaults, saving each change and - // flagging any restart-required ones. Only ever resets the one mod whose settings are open - never - // every mod - so it is called only from the mod-settings view. The default comes from the config.lua - // value captured by rom.mod_settings.load when available, and otherwise from the config entry's own - // stored default (so Chalk-bound mods, which never go through load, still reset). Returns true if any - // value actually changed. + // Restores the current mod's config entries (g_view_stem) to their defaults, saving each change and flagging any + // restart-required ones. Only ever resets the one mod whose settings are open - never every mod - so it is called + // only from the mod-settings view. The default comes from the config.lua value captured by rom.mod_settings.load + // when available, and otherwise from the config entry's own stored default (so. Chalk-bound mods, which never go + // through load, still reset). Returns true if any value actually changed. static bool reset_settings_to_defaults() { bool any_changed = false; @@ -2876,8 +3107,8 @@ namespace big::mod_settings auto def_val = get_setting_default(guid, def.m_section, def.m_key); if (!def_val) { - // Not bound via rom.mod_settings.load (e.g. a Chalk mod): recover the default from - // the config entry itself. + // Not bound via rom.mod_settings.load (e.g. a Chalk mod): recover the default from the config entry + // itself. def_val = entry_default_serialized(e); } if (!def_val || e->get_serialized_value() == *def_val) @@ -2885,7 +3116,7 @@ namespace big::mod_settings continue; } capture_restart_baseline(e); - e->set_serialized_value(*def_val); // auto-saves + fires on_setting_changed + e->set_serialized_value(*def_val); // auto-saves + fires on_setting_changed. note_change_if_restart_required(e, e->get_serialized_value()); any_changed = true; } @@ -2893,10 +3124,9 @@ namespace big::mod_settings return any_changed; } - // Handles a Reset activation on the Mods tab: restores the in-scope settings to their config.lua - // defaults, then (in a mod's settings view, where the changed values are on screen) queues a top - // rebuild so the widgets show the restored values. Safe to call from input/click context because - // the rebuild is deferred to the Update hook. + // Handles a Reset activation on the Mods tab: restores the in-scope settings to their config.lua defaults, then (in + // a mod's settings view, where the changed values are on screen) queues a top rebuild so the widgets show the + // restored values. Safe to call from input/click context because the rebuild is deferred to the Update hook. static void perform_reset() { const bool changed = reset_settings_to_defaults(); @@ -2910,24 +3140,22 @@ namespace big::mod_settings } } - // True when the game's current display language uses a CJK font (zh-CN, zh-TW, ja, ko). Those fonts - // have no glyph for the non-breaking space U+00A0 and draw a visible '*' instead, so the restart - // message uses regular spaces and a U+3000 blank for them. Every other language uses a - // Latin/Cyrillic/Greek font that renders U+00A0 invisibly - which is needed there to keep the - // (English) mod/setting entries from wrapping mid-line. + // True when the game's current display language uses a CJK font (zh-CN, zh-TW, ja, ko). Those fonts have no glyph + // for the non-breaking space U+00A0 and draw a visible '*' instead, so the restart message uses regular spaces and + // a U+3000 blank for them. Every other language uses a Latin/Cyrillic/Greek font that renders U+00A0 invisibly - + // which is needed there to keep the (English) mod/setting entries from wrapping mid-line. static bool current_language_is_cjk() { const std::string code = current_language_code(); return code.rfind("zh", 0) == 0 || code.rfind("ja", 0) == 0 || code.rfind("ko", 0) == 0; } - // Builds a locale-aware popup body: an intro line, a blank line, one line per list entry, a blank - // line, then an outro line (plus a sacrificial trailing blank). The character choices depend on the - // current locale's font (see current_language_is_cjk): CJK locales use regular spaces and a U+3000 - // ideographic-space blank line; all others use non-breaking spaces (U+00A0), which keep each - // intro/entry/outro line whole under the width-greedy formatter and double as the blank line. Both - // blank characters survive ShowText's ASCII-whitespace-line trim; the trailing blank is sacrificial - // because the formatter also trims the last whitespace-only line. Shared by the restart-required and + // Builds a locale-aware popup body: an intro line, a blank line, one line per list entry, a blank line, then an + // outro line (plus a sacrificial trailing blank). The character choices depend on the current locale's font (see + // current_language_is_cjk): CJK locales use regular spaces and a U+3000 ideographic-space blank line all others use + // non-breaking spaces (U+00A0), which keep each intro/entry/outro line whole under the width-greedy formatter and + // double as the blank line. Both blank characters survive ShowText's ASCII-whitespace-line trim. The trailing blank + // is sacrificial because the formatter also trims the last whitespace-only line. Shared by the restart-required and // dependency-block dialogs. static std::string build_list_message(const std::string& intro, const std::vector& lines, const std::string& outro) { @@ -2938,7 +3166,7 @@ namespace big::mod_settings { if (cjk) { - return s; // regular spaces render in the CJK font; the entries fit without non-breaking + return s; // regular spaces render in the CJK font, the entries fit without non-breaking } std::string out; out.reserve(s.size() + s.size() / 4); @@ -2974,9 +3202,9 @@ namespace big::mod_settings return build_list_message("A restart is required because you changed these settings:", lines, "The game will now close. Please restart it to apply the changes."); } - // Builds an empty EASTL SSO string (24-byte layout) in `buf` (>=24 bytes). Passed to the - // dialog ctor (message) and AddScreen (name); the real message is applied afterwards via - // ShowText. Layout: bytes[0..]=chars, byte[23]=remaining-capacity marker (23 - length). + // Builds an empty EASTL SSO string (24-byte layout) in `buf` (>=24 bytes). Passed to the dialog ctor (message) and + // AddScreen (name). The real message is applied afterwards via ShowText. Layout: bytes[0..]=chars, + // byte[23]=remaining-capacity marker (23 - length). static void make_eastl_sso(char* buf, const char* text) { std::size_t n = std::strlen(text); @@ -2989,14 +3217,14 @@ namespace big::mod_settings buf[23] = static_cast(23 - n); } - // Persists the game's native Options settings (language, audio volumes, resolution/window/graphics, - // and all gameplay/interface/accessibility toggles) to disk. The engine normally does this only - // when the options screen finishes closing (MiscSettingsScreen::OnExit -> ProfileManager::SaveProfile), - // which never runs when we force a restart. So any native settings the player changed earlier in the - // same options session would be lost. Call this immediately before terminating the process, using - // SaveProfile's synchronous path (async=false, no save spinner) so the files are written before we - // exit. Keybinds are excluded on purpose: they are saved separately when the Controls sub-screen - // closes, so they are already on disk by the time the player is back on the main options screen. + // Persists the game's native Options settings (language, audio volumes, resolution/window/graphics, and all + // gameplay/interface/accessibility toggles) to disk. The engine normally does this only when the options screen + // finishes closing (MiscSettingsScreen::OnExit -> ProfileManager::SaveProfile), which never runs when we force a + // restart. So any native settings the player changed earlier in the same options session would be lost. Call this + // immediately before terminating the process, using. SaveProfile's synchronous path (async=false, no save spinner) + // so the files are written before we exit. Keybinds are excluded on purpose: they are saved separately when the + // Controls sub-screen closes, so they are already on disk by the time the player is back on the main options + // screen. static void flush_native_settings() { if (g_save_profile && g_active_profile) @@ -3005,31 +3233,29 @@ namespace big::mod_settings } } - // Shows the native single-button message box (sgg::MessageDialog, the same box the game uses in the - // main menu for save/file errors), modal over the options screen, with `title` as the heading and - // `message` as the body. When confirm_closes_game is true the confirm button is captured so the - // OnClicked hook closes the game on press (used for a forced restart, which must not be - // cancellable); otherwise the button keeps its native behaviour and simply dismisses the dialog - // (used for informational prompts). Returns true if the dialog was shown. Returns false only if it - // could not be built (no screen manager or allocation failure). The dialog machinery is derived off - // the verified build anchor, so a mismatched game build disables the whole tab up front rather than - // reaching here. + // Shows the native single-button message box (sgg::MessageDialog, the same box the game uses in the main menu for + // save/file. Errors), modal over the options screen, with `title` as the heading and `message` as the body. When + // confirm_closes_game is true the confirm button is captured so the OnClicked hook closes the game on press (used + // for a forced restart, which must not be cancellable) otherwise the button keeps its native behaviour and simply + // dismisses the dialog (used for informational prompts). Returns true if the dialog was shown. Returns false only + // if it could not be built (no screen manager or allocation failure). The dialog machinery is derived off the + // verified build anchor, so a mismatched game build disables the whole tab up front rather than reaching here. static bool show_message_dialog(void* screen_manager, const char* title, const std::string& message, bool confirm_closes_game) { if (screen_manager && g_message_dialog_ctor && g_add_screen) { - // The game's ScreenManager owns and frees this screen (with _aligned_free) once it is - // dismissed. H2M's static /MT UCRT and the game's ucrtbase share the process heap, so this - // _aligned_malloc pairs safely with the game's _aligned_free - the same alloc/free split the - // num-box rows rely on (game factory allocates, destroy_rows frees). + // The game's ScreenManager owns and frees this screen (with _aligned_free) once. It is dismissed H2M's + // static /MT UCRT and the game's ucrtbase share the process heap, so this. _aligned_malloc pairs safely + // with the game's _aligned_free - the same alloc/free split the num-box rows rely on (game factory + // allocates, destroy_rows frees). void* dialog = _aligned_malloc(message_dialog_size, 8); if (dialog) { std::memset(dialog, 0, message_dialog_size); - // The ctor builds every component (single button + text) and loads - // GUI/MessageDialog.sjson. Pass an empty message; the real (multi-line) text is - // applied below via ShowText so it need not be an eastl heap string. + // The ctor builds every component (single button + text) and loads GUI/MessageDialog.sjson. Pass an + // empty message. The real (multi-line) text is applied below via ShowText so it need not be an eastl + // heap string. char empty_message[24]; make_eastl_sso(empty_message, ""); g_message_dialog_ctor(dialog, screen_manager, empty_message); @@ -3041,7 +3267,7 @@ namespace big::mod_settings bytes[screen_visible_offset] = 1; bytes[screen_block_input_offset] = 1; - // Set the title + body (raw text; the body carries the list of settings/mods). + // Set the title + body (raw text. The body carries the list of settings/mods). if (g_show_text) { if (auto* title_box = *reinterpret_cast(bytes + dialog_title_offset)) @@ -3050,22 +3276,23 @@ namespace big::mod_settings } if (auto* message_box = *reinterpret_cast(bytes + dialog_message_offset)) { - // Shrink the body font: the sjson template renders at size 26; scale the - // live font handle's size ratios down before ShowText lays out the lines - // (the def's mFontSize is ignored once the template is loaded). + // Shrink the body font: the sjson template renders at size 26 scale the live font handle's size + // ratios down before ShowText lays out the lines (the def's mFontSize is ignored once the + // template is loaded). char* handle = reinterpret_cast(message_box) + textbox_font_handle_offset; *reinterpret_cast(handle + font_handle_size_ratio_offset) *= restart_message_font_scale; *reinterpret_cast(handle + font_handle_eng_size_ratio_offset) *= restart_message_font_scale; - // Escape markup so a path value (e.g. hadesGameFolder) with '\' or brackets in - // the listed lines renders verbatim (see escape_markup). + + // Escape markup so a path value (e.g. hadesGameFolder) with '\' or brackets in the listed lines + // renders verbatim (see escape_markup). const std::string shown = escape_markup(message); g_show_text(message_box, shown.c_str()); } } - // Capture the confirm button only when it should close the game; otherwise the native - // confirm behaviour (dismiss the dialog) is left in place. Also remember the dialog so the - // OnClicked hook can confirm the clicked button still belongs to it before terminating. + // Capture the confirm button only when it should close the game. Otherwise the native confirm behaviour + // dismisses the dialog. Remember the dialog so the OnClicked hook can confirm the clicked button still + // belongs to it before terminating. if (confirm_closes_game) { g_restart_confirm_button = *reinterpret_cast(bytes + dialog_confirm_button_offset); @@ -3083,23 +3310,23 @@ namespace big::mod_settings return false; } - // The "restart required" prompt: its only button closes the game (a restart-required change must - // not be cancellable, since cancelling would have to undo the change). + // The "restart required" prompt: its only button closes the game (a restart-required change must not be + // cancellable, since cancelling would have to undo the change). static bool show_restart_dialog(void* screen_manager, const std::string& message) { return show_message_dialog(screen_manager, "Restart Required", message, /*confirm_closes_game*/ true); } - // The "can't disable this mod" prompt: purely informational, so its button just dismisses the - // dialog and returns the player to the options screen with the mod left enabled. + // The "can't disable this mod" prompt: purely informational, so its button just dismisses the dialog and returns + // the player to the options screen with the mod left enabled. static bool show_dependency_dialog(void* screen_manager, const std::string& message) { return show_message_dialog(screen_manager, "Cannot Disable Mod", message, /*confirm_closes_game*/ false); } - // True if the mod with config-file stem/guid `guid` is currently enabled: the value of its master - // "enabled" root-section toggle, or true when it has no such toggle (a mod with no enable switch is - // always active). Reads the live config value, so it reflects any change made this menu session. + // True if the mod with config-file stem/guid `guid` is currently enabled: the value of its master "enabled" + // root-section toggle, or true when it has no such toggle (a mod with no enable switch is always active). Reads the + // live config value, so it reflects any change made this menu session. static bool mod_is_enabled(const std::string& guid) { for (auto* cfg : toml_v2::config_file::g_config_files) @@ -3119,10 +3346,10 @@ namespace big::mod_settings return true; } - // Display names of the currently-enabled loaded mods that declare `stem` as a dependency (via their - // Thunderstore manifest, which lists dependency guids in dependencies_no_version_number). Disabling - // `stem` while any of these is enabled would break them, so the menu blocks it. A dependent that is - // itself disabled is skipped - it is not relying on `stem` right now. Sorted for a stable list. + // Display names of the currently-enabled loaded mods that declare `stem` as a dependency (via their Thunderstore + // manifest, which lists dependency guids in dependencies_no_version_number). Disabling `stem` while any of these is + // enabled would break them, so the menu blocks it. A dependent that is itself disabled is skipped -. It is not + // relying on `stem` right now. Sorted for a stable list. static std::vector active_dependents_of(const std::string& stem) { std::vector result; @@ -3152,10 +3379,10 @@ namespace big::mod_settings return result; } - // Body text for the dependency-block popup: lists the enabled mods depending on the one the player - // tried to disable, and tells them how to proceed. The intro/outro are kept short so they fit the - // dialog width on every locale (the wider CJK fonts overflow a long line); the blocked mod is - // identified by the dialog title and the toggle the player just clicked, so it is not repeated here. + // Body text for the dependency-block popup: lists the enabled mods depending on the one the player tried to + // disable, and tells them how to proceed. The intro/outro are kept short so they fit the dialog width on every + // locale (the wider CJK fonts overflow a long line). The blocked mod is identified by the dialog title and the + // toggle the player just clicked, so it is not repeated here. static std::string build_dependency_message(const std::vector& dependents) { return build_list_message("These enabled mods depend on this one:", dependents, "Disable them first to disable this mod."); @@ -3163,9 +3390,9 @@ namespace big::mod_settings static void* hook_MiscSettingsScreen_ctor(void* self, void* screen_manager, void* opened_from, void* profile_name) { - // Reset state BEFORE running the original ctor: the original ctor immediately shows - // the last-viewed category, and if that is the Mods tab it builds our panel via - // DoShowCategory. Clearing g_rows after the original would wipe those fresh rows. + // Reset state BEFORE running the original ctor: the original ctor immediately shows the last-viewed category, + // and if that is the Mods tab it builds our panel via DoShowCategory. Clearing g_rows after the original would + // wipe those fresh rows. g_rows.clear(); g_view = View::mod_list; g_view_stem.clear(); @@ -3186,12 +3413,13 @@ namespace big::mod_settings g_prompt_cancel_label.clear(); exit_edit_mode(); - // Record whether the screen was opened during gameplay (a save loaded) or from the main menu, - // so context-restricted rows can be greyed. Must be set before the original ctor runs, which - // shows the last-viewed category and may build our panel via DoShowCategory. - g_opened_in_game = opener_indicates_in_game(opened_from); + // Record whether the screen was opened during gameplay (a save loaded) or from the main menu, so + // context-restricted rows can be greyed. Must be set before the original ctor runs, which shows the last-viewed + // category and may build our panel via DoShowCategory. + g_opened_in_game = opener_indicates_in_game(opened_from); + g_options_screen_open = true; - // The engine constructor returns `this`; forward it unchanged. + // The engine constructor returns `this` forward it unchanged auto* screen = static_cast(big::g_hooking->get_original()(self, screen_manager, opened_from, profile_name)); if (!mods_category_button(screen)) @@ -3209,13 +3437,13 @@ namespace big::mod_settings auto* screen = static_cast(self); const bool is_mods_tab = category_button && category_button == reinterpret_cast(screen->m_editor_options_button); - // Leaving the Mods tab for another category: tear our rows down FIRST, before the native - // category switch runs. The native switch only unlinks the outgoing category's mOptions entries - // from mComponents; our right-column value components are in mComponents but NOT mOptions (they - // are drawn, not paged), so the native teardown would leave them behind. They would then linger - // in mComponents on the other category - re-localized by a language change and walked by the - // native layout - which can corrupt unrelated widgets (e.g. a category button's label). Doing - // our own teardown here keeps mComponents clean for the native code; re-entering the tab rebuilds. + // Leaving the Mods tab for another category: tear our rows down FIRST, before the native category switch runs. + // The native switch only unlinks the outgoing category's mOptions entries from mComponents our right-column + // value components are in mComponents but NOT mOptions (they are drawn, not paged), so the native teardown + // would leave them behind. They would then linger in mComponents on the other category - re-localized by a + // language change and walked by the native layout - which can corrupt unrelated widgets (e.g. a category + // button's label). Doing our own teardown here keeps mComponents clean for the native code re-entering the tab + // rebuilds. if (!is_mods_tab && !g_rows.empty()) { destroy_rows(screen); @@ -3228,13 +3456,13 @@ namespace big::mod_settings if (is_mods_tab) { - // Entering the tab always starts at the mod list; drill-down happens in-place - // via the Update hook, not by re-entering the category. + // Entering the tab always starts at the mod list drill-down happens in-place via the Update hook, not by + // re-entering the category. g_view = View::mod_list; g_view_stem.clear(); g_view_section.clear(); g_nav_pending = false; - g_nav_stack.clear(); // a fresh tab entry starts at the top of the mod list + g_nav_stack.clear(); // a fresh tab entry starts at the top of the mod list. g_has_pending_restore = false; exit_edit_mode(); build_panel(screen); @@ -3243,13 +3471,12 @@ namespace big::mod_settings return result; } - // Value-change hook for our native number-box rows. GUIComponentNumBox::SetNumberValue is called - // (with notify=true) on every user step - left/right, arrow click, keyboard or controller. We run - // the original first (it clamps to [min,max], refreshes the value text, updates arrow visibility), - // then, if `this` is one of our rows, persist the post-clamp value to the config entry and run the - // restart-required tracking. `notify` is false only for our own initial paint in make_numbox_row, - // so filtering on it keeps that from being recorded as a change. This fires for native settings - // num-boxes too, hence the `find_row` filter. + // Value-change hook for our native number-box rows. GUIComponentNumBox::SetNumberValue is called (with notify=true) + // on every user step - left/right, arrow click, keyboard or controller. We run the original first (it clamps to + // [min,max], refreshes the value text, updates arrow visibility), then, if `this` is one of our rows, persist the + // post-clamp value to the config entry and run the restart-required tracking `notify` is false only for our own + // initial paint in make_numbox_row, so filtering on it keeps that from being recorded as a change. This fires for + // native settings num-boxes too, hence the `find_row` filter. static void hook_GUIComponentNumBox_SetNumberValue(void* self, float value, bool notify) { big::g_hooking->get_original()(self, value, notify); @@ -3265,8 +3492,8 @@ namespace big::mod_settings return; } - // Enum cycler: the box tracks the option index; persist the matching serialized value and - // replace the raw index the original just painted with the option's label. + // Enum cycler: The box tracks the option index persist the matching serialized value and replace the raw index + // the original just painted with the option's label. if (row->is_enum) { int idx = static_cast(*reinterpret_cast(reinterpret_cast(self) + numbox_value_offset)); @@ -3282,6 +3509,12 @@ namespace big::mod_settings capture_restart_baseline(row->entry); row->entry->set_serialized_value(serialized); // auto-saves via on_setting_changed note_change_if_restart_required(row->entry, row->enum_labels[idx]); + + // Re-evaluate dynamic rows once the presses settle (see g_dynamic_refresh_settle). + if (g_view_has_dynamic) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } } return; } @@ -3300,20 +3533,23 @@ namespace big::mod_settings capture_restart_baseline(row->entry); row->entry->set_value_base(new_value); // auto-saves via on_setting_changed note_change_if_restart_required(row->entry, row->entry->get_serialized_value()); + + // Re-evaluate dynamic rows once the arrow presses settle (see g_dynamic_refresh_settle). + if (g_view_has_dynamic) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } } - // Value-change hook for our native slider rows. GUIComponentSlider::SetFraction is called with - // notify=true on every user drag / left-right adjust (the native handler also rewrites the value - // text to a percentage). We run the original, then, for our rows, map the post-clamp fraction to the - // [min,max] value, snap that to the setting's step for storage/display, and restore the real value - // text. notify is false only for our own initial paint (make_slider_row), so filtering on it skips - // that. Fires for the native audio sliders too, hence the find_row filter. - // - // We deliberately leave mFraction continuous (we do NOT write the snapped value back to it): the - // native adjust accumulates a small per-frame delta into mFraction, so re-snapping it each frame - // would discard any delta smaller than half a step and a partial stick deflection would never move - // the slider. The fill therefore tracks the stick smoothly (as the vanilla sliders do) while the - // stored value and the value text snap to step. + // Value-change hook for our native slider rows GUIComponentSlider::SetFraction is called with notify=true on every + // user drag / left-right adjust (the native handler also rewrites the value text to a percentage). We run the + // original, then, for our rows, map the post-clamp fraction to the [min,max] value, snap that to the setting's step + // for storage/display, and restore the real value text notify is false only for our own initial paint + // (make_slider_row), so filtering on it skips that. Fires for the native audio sliders too, hence the find_row + // filter. We deliberately leave mFraction continuous (we do NOT write the snapped value back to it): the native + // adjust accumulates a small per-frame delta into mFraction, so re-snapping it each frame would discard any delta + // smaller than half a step and a partial stick deflection would never move the slider. The fill therefore tracks + // the stick smoothly (as the vanilla sliders do) while the stored value and the value text snap to step. static void hook_GUIComponentSlider_SetFraction(void* self, float fraction, bool notify) { big::g_hooking->get_original()(self, fraction, notify); @@ -3355,25 +3591,30 @@ namespace big::mod_settings capture_restart_baseline(row->entry); row->entry->set_value_base(v); // auto-saves via on_setting_changed note_change_if_restart_required(row->entry, row->entry->get_serialized_value()); + + // Re-evaluate dynamic rows once the drag settles (see g_dynamic_refresh_settle) + if (g_view_has_dynamic) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } } - // Restore the real value in place of the percentage the original wrote (applying the setting's - // own percentage-display options). + // Restore the real value in place of the percentage the original wrote (applying the setting's own + // percentage-display options). set_slider_value_text(reinterpret_cast(self), format_setting_display(v, row->show_as_percentage, row->is_percentage, step_v).c_str()); } - // Button-click hook. GUIComponentButton overrides GUIComponent::OnClicked (vtable slot - // +0x100, the engine's terminal-click), so this is where our button rows' clicks land. - // For our rows the engine returns false (they have no bound activate function) but still - // plays the press sound, so we must match the row regardless of the return value. The - // actual panel rebuild is deferred to the Update hook, where mutating the component - // vectors is safe (this runs mid input iteration). + // Button-click hook GUIComponentButton overrides. GUIComponent::OnClicked (vtable slot +0x100, the engine's + // terminal-click), so this is where our button rows' clicks land. For our rows the engine returns false (they have + // no bound activate function) but still plays the press sound, so we must match the row regardless of the return + // value. The actual panel rebuild is deferred to the Update hook, where mutating the component vectors is safe + // (this runs mid input iteration). static bool hook_GUIComponentButton_OnClicked(GUIComponent* self, std::uint64_t location) { - // Clicking the restart message box's button closes the game (forced restart). Re-validate the - // button's owner is still the restart dialog so a rebuilt row that happened to reuse the freed - // button's address (if the dialog were ever dismissed without confirming) cannot trigger it. + // Clicking the restart message box's button closes the game (forced restart). Re-validate the button's owner is + // still the restart dialog so a rebuilt row that happened to reuse the freed button's address (if the dialog + // were ever dismissed without confirming) cannot trigger it. if (self && self == g_restart_confirm_button && g_restart_dialog && *reinterpret_cast(reinterpret_cast(self) + sgg::gui_component_button_owner_offset) == g_restart_dialog) { big::g_hooking->get_original()(self, location); @@ -3418,16 +3659,17 @@ namespace big::mod_settings case RowKind::setting: { auto* entry = matched_row.entry; - // Boolean settings toggle in place; other types open a freetext editor. Number-box - // (stepper) rows are GUIComponentNumBox, not buttons, so their clicks never reach - // this hook - the num-box handles its own arrow clicks and left/right natively. + + // Boolean settings toggle in place other types open a freetext editor. Number-boxNumber-box (stepper) + // rows are GUIComponentNumBox, not buttons, so their clicks never reach this hook - the num-box handles + // its own arrow clicks and left/right natively. if (entry && entry->type() == typeid(bool)) { const bool new_value = !entry->get_value_base(); - // Block disabling a mod that other enabled mods still depend on: turning the mod's - // master "enabled" switch off would break them. Leave the toggle on and show an - // informational popup listing the dependents (its button just dismisses the popup). + // Block disabling a mod that other enabled mods still depend on: turning the mod's master "enabled" + // switch off would break them. Leave the toggle on and show an informational popup listing the + // dependents (its button just dismisses the popup). if (matched_row.is_enabled_toggle && !new_value) { const std::vector dependents = active_dependents_of(matched_row.stem); @@ -3436,24 +3678,26 @@ namespace big::mod_settings void* owner = *reinterpret_cast(reinterpret_cast(self) + sgg::gui_component_button_owner_offset); void* screen_manager = owner ? *reinterpret_cast(reinterpret_cast(owner) + screen_manager_offset) : nullptr; show_dependency_dialog(screen_manager, build_dependency_message(dependents)); - break; // do not disable; the toggle stays on + break; // do not disable, the toggle stays on } } - // Capture the session baseline before the first write so a later revert to - // it (toggling off then on again) is recognised as "no net change". + // Capture the session baseline before the first write so a later revert to it (toggling off then on + // again) is recognised as "no net change". capture_restart_baseline(entry); entry->set_value_base(new_value); set_toggle_graphic(self, new_value); - // If the author declared this setting restart-required, flag/clear the - // restart and record the change so the popup can list what forced it. + // If the author declared this setting restart-required, flag/clear the restart and record the + // change so the popup can list what. Forced it. note_change_if_restart_required(entry, new_value ? "on" : "off"); - // Toggling the mod's master "enabled" switch changes which other rows - // are greyed out, so rebuild the settings view on the next Update. - if (matched_row.is_enabled_toggle) + // Toggling the mod's master "enabled" switch changes which other rows are greyed toggling any bool + // in a view that has dynamic (function) rows may change their disabled/hidden/range - so rebuild + // the settings view in place on the next Update to re-evaluate them live. The rebuild is instant + // (same view), preserving scroll. + if (matched_row.is_enabled_toggle || g_view_has_dynamic) { g_pending_view = View::mod_settings; g_pending_stem = matched_row.stem; @@ -3468,7 +3712,15 @@ namespace big::mod_settings break; } case RowKind::action: - // TODO: dispatch the action row's callback. + + // Run the author's Lua callback, then rebuild the current view: the callback may have changed config + // values (e.g. a "Reset" button) or dynamic ranges, so the rows need to re-read them. Mirrors the + // master-toggle rebuild path. + invoke_action(matched_row.stem, matched_row.target_section, matched_row.setting_key); + g_pending_view = View::mod_settings; + g_pending_stem = matched_row.stem; + g_pending_section = g_view_section; + g_nav_pending = true; break; } } @@ -3476,17 +3728,33 @@ namespace big::mod_settings return result; } - // Per-frame screen update: RCX=this, XMM1=dt (float), R8=input. We apply any queued - // navigation here because the click/input iteration has fully unwound by now, so - // tearing down and rebuilding the component vectors is safe. We rebuild before the - // original runs so this frame lays out and hover-resolves the new rows. + // Detour on the native scroll pass. The original lays every on-page row on the uniform grid (writing each row's + // mLocation), so it is the point where our action-button spacing must be (re)applied: running it here, inside + // MiscSettingsScreen::Update BEFORE the row hit-test in MenuScreen::Update, keeps the hover/click rects aligned + // with the drawn (shifted) buttons. Applying the shift after the original Update instead left the hit-test on the + // unshifted grid, so a button's hover box sat above its visual and bled into the row above. + static void hook_MiscSettingsScreen_UpdateScrollState(void* self) + { + big::g_hooking->get_original()(self); + + auto* screen = static_cast(self); + const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); + if (on_mods_tab) + { + sync_button_spacing(screen); + } + } + + // Per-frame screen update: RCX=this, XMM1=dt (float), R8=input. We apply any queued navigation here because the + // click/input iteration has fully unwound by now, so tearing down and rebuilding the component vectors is safe. We + // rebuild before the original runs so this frame lays out and hover-resolves the new rows. static void* hook_MiscSettingsScreen_Update(void* self, float dt, void* input) { auto* screen = static_cast(self); const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); - // Freetext editing: refresh the edited row's live label. Confirm/cancel is handled in - // the HandleInput hook so the submitting key/click is swallowed on the same frame. + // Freetext editing: refresh the edited row's live label Confirm/cancel is handled in the HandleInput hook so + // the submitting key/click is swallowed on the same frame. if (g_editing) { if (on_mods_tab) @@ -3499,16 +3767,48 @@ namespace big::mod_settings } } + // A slider drag, number-box adjust, or freetext commit in a view that has dynamic (function) rows re-evaluates + // those rows (e.g. an apply button enabling itself when a value changes). The rebuild frees and recreates the + // rows, so it is deferred two ways: a short debounce absorbs the per-frame slider hook, and while the user is + // still interacting with a row (adjusting it with keyboard/controller, or hovering/dragging it with the mouse) + // the rebuild is HELD until they move off it - otherwise it would free the focused slider mid-adjust or + // interrupt a mouse drag. + if (g_dynamic_refresh_settle > 0.0f) + { + if (!on_mods_tab) + { + g_dynamic_refresh_settle = 0.0f; + } + else if (interacting_with_row(screen)) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; // hold until they leave the row + } + else + { + g_dynamic_refresh_settle -= dt; + if (g_dynamic_refresh_settle <= 0.0f) + { + g_dynamic_refresh_settle = 0.0f; + if (!g_nav_pending) + { + g_pending_view = g_view; + g_pending_stem = g_view_stem; + g_pending_section = g_view_section; + g_nav_pending = true; + } + } + } + } + if (g_nav_pending) { // Only act while this screen is actually showing the Mods tab. if (on_mods_tab) { - // Stepping from a mod's settings back to the mod overview is the "done configuring this - // mod" point: if a restart-required setting changed this session, show the restart prompt - // now (it forces the restart) and stay on the current view under it, rather than - // returning to the overview. Only the final step out of the mod (to the list) triggers - // it; stepping between nested groups stays within mod_settings. + // Stepping from a mod's settings back to the mod overview is the "done configuring this mod" point: if + // a restart-required setting changed this session, show the restart prompt now (it forces the restart) + // and stay on the current view under it, rather than returning to the overview. Only the final step out + // of the mod (to the list) triggers it stepping between nested groups stays within mod_settings. const bool leaving_mod = (g_view == View::mod_settings) && (g_pending_view == View::mod_list); bool prompted = false; if (leaving_mod && g_restart_required && !g_restart_prompt_shown) @@ -3527,9 +3827,8 @@ namespace big::mod_settings void* result = big::g_hooking->get_original()(self, dt, input); - // The original just laid out the key rows for this frame; mirror the value columns - // onto them so the right column tracks scrolling and fade, and show the highlighted - // row's description in the native description box. + // The original just laid out the key rows for this frame mirror the value columns onto them so the right column + // tracks scrolling and fade, and show the highlighted row's description in the native description box. if (on_mods_tab) { sync_scroll_fade(screen); @@ -3537,28 +3836,25 @@ namespace big::mod_settings sync_description_box(screen); } - // Retune the bottom prompt buttons per context (off the Mods tab this only clears our - // caches and leaves the native prompts alone). + // Retune the bottom prompt buttons per context (off the Mods tab this only clears our caches and leaves the + // native prompts alone). sync_prompts(screen, on_mods_tab); return result; } - // While a freetext setting is being edited, read Enter (confirm) and Escape (cancel) from the game's - // own per-frame input, commit/cancel here, then swallow the screen's input handling entirely so menu - // navigation and the Escape-to-close do not react. Committing here (rather than in Update) is - // important: HandleInput returns true this frame, so a submitting mouse click is swallowed and cannot - // also activate the row it lands on. Returning true without calling the original bypasses the whole - // close chain (the base MenuScreen::HandleInput is only reached via this function's tail-call). - // - // Not editing, controller/keyboard, nothing entered yet: we drive two per-option behaviours the - // native focus delegates would (which our injected rows lack). Select (A / Enter) enters a slider or - // enum row so the stick then adjusts it; the native code exits it on the next A/B. And inside a mod's - // settings, Back/Cancel (controller B / keyboard Esc) steps back one level - in option-navigation - // mode the native Cancel handler returns the cursor to the tab bar instead of reaching our ExitScreen - // back-nav, so we detect it here (before the original) and run the back-nav ourselves. Both swallow - // the press. When a widget is already entered we do nothing: native routes the stick to it and exits - // on A/B. + // While a freetext setting is being edited, read Enter (confirm) and Escape (cancel) from the game's own per-frame + // input, commit/cancel here, then swallow the screen's input handling entirely so menu navigation and the + // Escape-to-close do not react. Committing here (rather than in Update) is important: HandleInput returns true this + // frame, so a submitting mouse click is swallowed and cannot also activate the row it lands on. Returning true + // without calling the original bypasses the whole close chain (the base. MenuScreen::HandleInput is only reached + // via this function's tail-call). Not editing, controller/keyboard, nothing entered yet: we drive two per-option + // behaviours the native focus delegates would (which our injected rows lack). Select (A / Enter) enters a slider or + // enum row so the stick then adjusts it. The native code exits it on the next. A/B And inside a mod's settings,. + // Back/CancelBack/Cancel (controller B / keyboard Esc) steps back one level - in option-navigation mode the native + // Cancel handler returns the cursor to the tab bar instead of reaching our. ExitScreen back-nav, so we detect it + // here (before the original) and run the back-nav ourselves. Both swallow the press. When a widget is already + // entered we do nothing: native routes the stick to it and exits on. A/B. static bool hook_MiscSettingsScreen_HandleInput(void* self, void* input, float x) { if (g_editing) @@ -3584,8 +3880,8 @@ namespace big::mod_settings { auto* menu = reinterpret_cast(screen); - // Select enters a slider / enum row (so the stick adjusts it); toggles and buttons are left - // to the native component pass. + // Select enters a slider / enum row (so the stick adjusts it) toggles and buttons are left to the native + // component pass. if (g_component_focused && control_pressed(input, g_controls_select)) { PanelRow* row = find_row(menu->m_mouse_over_component); @@ -3596,10 +3892,9 @@ namespace big::mod_settings } } - // Back/Cancel inside a mod's settings steps back one level (nested group -> parent section, - // root -> mod list) instead of the native return-to-tab-bar. In the mod list it is left to - // the native handler. The restart prompt is shown when stepping from a mod's settings back - // to the overview (see apply_nav). + // Back/Cancel inside a mod's settings steps back one level (nested group -> parent section, root -> mod + // list) instead of the native return-to-tab-bar In the mod list. It is left to the native handler. The + // restart prompt is shown when stepping from a mod's settings back to the overview (see apply_nav). if (g_view == View::mod_settings && !g_nav_pending && control_pressed(input, g_controls_cancel)) { request_back_nav(); @@ -3610,25 +3905,24 @@ namespace big::mod_settings return big::g_hooking->get_original()(self, input, x); } - // Close funnel for the options screen: every way the user dismisses it (Escape key, controller - // B, or clicking the on-screen "Exit" button) converges here (MiscSettingsScreen::ExitScreen, - // vtable slot 7), before any fade/teardown and while mScreenManager is valid. If a restart is - // required, show the native message box and DO NOT run the original (veto the close): the box - // is modal over the still-open options screen and its button closes the game. A restart-required - // change must not be cancellable (that would require undoing the change), so the restart is - // forced. If the native dialog cannot be built, the change is already saved to the mod's config - // (it applies on the next manual restart), so we just let the screen close normally. + // Close funnel for the options screen: every way the user dismisses it (Escape key, controller B, or clicking the + // on-screen "Exit" button) converges here (MiscSettingsScreen::ExitScreen, vtable slot 7), before any fade/teardown + // and while mScreenManager is valid. If a restart is required, show the native message box and DO NOT run the + // original (veto the close): The box is modal over the still-open options screen and its button closes the game. A + // restart-required change must not be cancellable (that would require undoing the change), so the restart is. + // Forced. If the native dialog cannot be built, the change is already saved to the mod's config (it applies on the + // next manual restart), so we just let the screen close normally. static void hook_MiscSettingsScreen_ExitScreen(void* self) { - // Inside a mod's settings, Esc / controller B / the on-screen Back button steps up one level: - // a nested group returns to its parent section, and the root returns to the mod list. Only the - // mod-list view actually closes the options screen. + // Inside a mod's settings, Esc / controller B / the on-screen Back button steps up one level: a nested group + // returns to its parent section, and the root returns to the mod list. Only the mod-list view actually closes + // the options screen. auto* screen = static_cast(self); const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); if (on_mods_tab && g_view == View::mod_settings) { request_back_nav(); - return; // veto the close; apply_nav applies the new view next Update + return; // veto the close, apply_nav applies the new view next Update } if (g_restart_required && !g_restart_prompt_shown) @@ -3641,22 +3935,23 @@ namespace big::mod_settings } } - // The screen is really closing now. Tear our rows down first: the engine frees a MenuScreen's - // components through its reflection helper (which our rows are deliberately not registered in), - // not by walking mComponents, so on close it would neither free nor double-free them - they would - // just leak. destroy_rows is a no-op when g_rows is already empty (e.g. closing off the Mods tab). + // The screen is really closing now. Tear our rows down first: the engine frees a MenuScreen's components + // through its reflection helper (which our rows are deliberately not registered in), not by walking + // mComponents, so on close it would neither free nor double-free them - they would just leak destroy_rows is a + // no-op when g_rows is already empty (e.g. closing off the Mods tab). + g_options_screen_open = false; // stop gating on_change on this now-closing screen. + g_dynamic_refresh_settle = 0.0f; // drop any pending numeric-change refresh for the closing screen. destroy_rows(screen); exit_edit_mode(); big::g_hooking->get_original()(self); } - // Reset choke-point: sgg::MiscSettingsScreen::RestoreDefaults (virtual slot 21) is the single - // handler for both the [I]/MenuInfo control and a mouse click on the on-screen Reset button. On - // our Mods tab the native reset is a no-op (our rows' mDataValue is not a ConfigOptionsField key). - // Inside a single mod's settings we run our own reset of that mod's config and still call the - // original for the native confirm animation + sound and glyph refresh (on our tab it touches no - // real game settings). In the mod list/overview we swallow it entirely: Reset is intentionally + // Reset choke-point: sgg::MiscSettingsScreen::RestoreDefaults (virtual slot 21) is the single handler for both the + // [I]/MenuInfo control and a mouse click on the on-screen Reset button. On our Mods tab the native reset is a no-op + // (our rows' mDataValue is not a ConfigOptionsField key). Inside a single mod's settings we run our own reset of + // that mod's config and still call the original for the native confirm animation + sound and glyph refresh (on our + // tab it touches no real game settings) In the mod list/overview we swallow it entirely: Reset is intentionally // unavailable there (its prompt is hidden too) so users can't reset every mod's config by mistake. static void hook_MiscSettingsScreen_RestoreDefaults(void* self) { @@ -3665,7 +3960,7 @@ namespace big::mod_settings { if (g_view != View::mod_settings) { - return; // reset is unavailable in the mod list; do nothing (and do not play the native reset) + return; // reset is unavailable in the mod list, do nothing (and do not play the native reset) } perform_reset(); } @@ -3675,13 +3970,12 @@ namespace big::mod_settings void register_hooks() { - // Resolve every engine symbol, RVA and offset the Mods tab depends on up front. The symbol map - // is built from the game's live PDB, so if the game updates and a required function moved or was - // renamed it resolves to null here; likewise the hardcoded RVAs and struct offsets this feature - // was reverse-engineered against only match one specific Ship build. If anything required is - // missing we log exactly what and install NO hooks, so the tab is cleanly skipped instead of - // crashing the game. The rom.mod_settings Lua config API is wired separately (bind_config_api) - // and keeps working regardless, so mods can still author and read their config. + // Resolve every engine symbol, RVA and offset the Mods tab depends on up front. The symbol map is built from + // the game's live PDB, so if the game updates and a required function moved or was renamed it resolves to null + // here likewise the hardcoded RVAs and struct offsets this feature was reverse-engineered against only match + // one specific Ship build. If anything required is missing we log exactly what and install NO hooks, so the tab + // is cleanly skipped instead of crashing the game. The rom.mod_settings Lua config API is wired separately + // (bind_config_api) and keeps working regardless, so mods can still author and read their config. std::vector missing; const auto require = [&](const char* name) -> gmAddress { @@ -3698,12 +3992,13 @@ namespace big::mod_settings const auto do_show_category = require("sgg::MiscSettingsScreen::DoShowCategory"); const auto on_clicked = require("sgg::GUIComponentButton::OnClicked"); const auto update = require("sgg::MiscSettingsScreen::Update"); + const auto update_scroll = require("sgg::MiscSettingsScreen::UpdateScrollState"); const auto handle_input = require("sgg::MiscSettingsScreen::HandleInput"); const auto set_number_value = require("sgg::GUIComponentNumBox::SetNumberValue"); - // Engine helpers called while building and editing rows. A null call here would crash, so every - // one is required. The button ctor doubles as the RVA anchor for the templated/overloaded - // helpers resolved further down. + // Engine helpers called while building and editing rows. A null call here would crash, so every one is + // required. The button ctor doubles as the RVA anchor for the templated/overloaded helpers resolved further + // down. const auto anchor = require("sgg::GUIComponentButton::GUIComponentButton"); g_button_ctor = anchor.as_func(); g_set_label = require("sgg::GUIComponentButton::SetDisplayName").as_func(); @@ -3721,18 +4016,17 @@ namespace big::mod_settings g_push_back = big::hades2_symbol_to_address["eastl::vector::push_back"].as_func(); - // Optional helpers: every call site is null-guarded, so their absence only degrades a visual or - // teardown detail (never crashes) and must not gate the feature. + // Optional helpers: every call site is null-guarded, so their absence only degrades a visual or teardown detail + // (never crashes) and must not gate the feature. g_get_lines = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GetLines"].as_func(); g_set_selected_texture = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetSelectedTexture"].as_func(); g_button_dtor = big::hades2_symbol_to_address["sgg::GUIComponentButton::~GUIComponentButton"].as_func(); g_disable = big::hades2_symbol_to_address["sgg::GUIComponentButton::Disable"].as_func(); - // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the - // number-box stepper). The engine has no slider factory, so a slider is hand-built from the base - // GUIComponent / image / text-box constructors and Defaults - all resolved by name here. - // SetFraction is both the initial set and the drag hook (installed below); the slider vtable is - // addressed by RVA off the anchor once the build is verified. + // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the number-box + // stepper). The engine has no slider factory, so a slider is hand-built from the base GUIComponent / image / + // text-box constructors and Defaults - all resolved by name here. SetFraction is both the initial set and the + // drag hook (installed below). The slider vtable is addressed by RVA off the anchor once the build is verified. g_gui_component_ctor = big::hades2_symbol_to_address["sgg::GUIComponent::GUIComponent"].as_func(); g_image_ctor = big::hades2_symbol_to_address["sgg::GUIComponentImage::GUIComponentImage"].as_func(); g_textbox_ctor = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GUIComponentTextBox"].as_func(); @@ -3740,33 +4034,30 @@ namespace big::mod_settings const auto slider_set_fraction = big::hades2_symbol_to_address["sgg::GUIComponentSlider::SetFraction"]; g_slider_set_fraction = slider_set_fraction.as_func(); - // Controller focus: ComponentFocused makes a row the focused option (so the stick reaches it), - // GetState reads the Back/Cancel control edge for our drilldown back-nav. Both by name; the - // Controls::Cancel address is RVA-relative (resolved below). Optional - their absence only - // degrades controller support, not the tab. + // Controller focus: ComponentFocused makes a row the focused option (so the stick reaches it),. GetState reads + // the Back/Cancel control edge for our drilldown back-nav. Both by name. The Controls::Cancel address is + // RVA-relative (resolved below). Optional - their absence only degrades controller support, not the tab. g_component_focused = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ComponentFocused"].as_func(); g_input_get_state = big::hades2_symbol_to_address["sgg::InputHandler::GetState"].as_func(); - // Native-settings flush before a forced restart: SaveProfile serializes the active profile - // (language, volumes, graphics, gameplay/interface toggles) to disk; ACTIVE_PROFILE is the - // profile-name string it takes. Both are named PDB globals/functions. Optional - if either is - // missing we simply skip the flush (the forced restart still happens), so native changes made - // this session would be lost, but nothing crashes. + // Native-settings flush before a forced restart. SaveProfile. SaveProfile serializes the active profile + // (language, volumes, graphics, gameplay/interface toggles) to disk. ACTIVE_PROFILE is the profile-name string + // it takes. Both are named PDB globals/functions. Optional - if either is missing we simply skip the flush (the + // forced restart still happens), so native changes made this session would be lost, but nothing crashes. g_save_profile = big::hades2_symbol_to_address["sgg::ProfileManager::SaveProfile"].as_func(); g_active_profile = big::hades2_symbol_to_address["sgg::ProfileManager::ACTIVE_PROFILE"].as(); - // The num-box factory (a template instantiation) and the restart-dialog ctor / AddScreen - // overloads cannot be picked by name from the PDB, so they are addressed by hardcoded RVA off - // the button-ctor anchor. Those RVAs - and every struct offset this feature uses - are valid - // only for the Ship build they were captured from. Fingerprint that build by checking the - // anchor sits at its known module RVA (game base taken from the live process). A mismatch means - // the game changed and our RVAs/offsets can no longer be trusted, so disable the whole tab. + // The num-box factory (a template instantiation) and the restart-dialog ctor /. AddScreen overloads cannot be + // picked by name from the PDB, so they are addressed by hardcoded RVA off the button-ctor anchor. Those RVAs - + // and every struct offset this feature uses - are valid only for the Ship build they were captured. Fingerprint + // that build by checking the anchor sits at its known module RVA (game base taken from the live process). A + // mismatch means the game changed and our RVAs/offsets can no longer be trusted, so disable the whole tab. uintptr_t game_base = 0; std::size_t game_size = 0; ::module_info_helper::get_module_base_and_size(&game_base, &game_size, nullptr); const bool build_matches = anchor && game_base && (anchor.as() - game_base == anchor_rva); - // push_back is a named PDB symbol but is occasionally emitted inline; fall back to its RVA. + // push_back is a named PDB symbol but is occasionally emitted inline fall back to its RVA if (!g_push_back && build_matches) { g_push_back = reinterpret_cast(anchor.as() - anchor_rva + push_back_rva); @@ -3815,9 +4106,9 @@ namespace big::mod_settings "sgg::MiscSettingsScreen::DoShowCategory", do_show_category); - // All required by the checks above, so install unconditionally. OnClicked and SetNumberValue are - // global (they fire for every button / num-box in the game); their callbacks filter to our rows - // via find_row, so installing them is a no-op for the rest of the game's UI. + // All required by the checks above, so install unconditionally. OnClicked and SetNumberValue are global (they + // fire for every button / num-box in the game). Their callbacks filter to our rows via find_row, so installing + // them is a no-op for the rest of the game's UI. static auto onclick_hook = hooking::detour_hook_helper::add_queue( "sgg::GUIComponentButton::OnClicked", on_clicked); @@ -3825,20 +4116,21 @@ namespace big::mod_settings "sgg::GUIComponentNumBox::SetNumberValue", set_number_value); - // Optional: persists user drags on our slider rows (filtered to our rows via find_row, so it is a - // no-op for the native audio sliders). If absent, bounded numbers render as the number-box stepper. + // Optional: persists user drags on our slider rows (filtered to our rows via find_row, so it is a no-op for the + // native audio sliders). If absent, bounded numbers render as the number-box stepper. if (slider_set_fraction) { static auto set_fraction_hook = hooking::detour_hook_helper::add_queue("sgg::GUIComponentSlider::SetFraction", slider_set_fraction); } static auto update_hook = hooking::detour_hook_helper::add_queue("sgg::MiscSettingsScreen::Update", update); + static auto update_scroll_hook = hooking::detour_hook_helper::add_queue("sgg::MiscSettingsScreen::UpdateScrollState", update_scroll); static auto handle_input_hook = hooking::detour_hook_helper::add_queue( "sgg::MiscSettingsScreen::HandleInput", handle_input); - // Every close path (Escape key, controller B, clicking the on-screen Exit button) funnels - // through ExitScreen, so this is where the restart-required prompt is triggered. + // Every close path (Escape key, controller B, clicking the on-screen Exit button) funnels through ExitScreen, + // so this is where the restart-required prompt is triggered. const auto exit_screen = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ExitScreen"]; if (exit_screen) { @@ -3850,8 +4142,8 @@ namespace big::mod_settings "will not appear"; } - // Optional: the on-screen "Reset" button ([I]/MenuInfo control or mouse) funnels through - // RestoreDefaults. Without it the Mods tab still works; Reset just won't restore mod defaults. + // Optional: the on-screen "Reset" button ([I]/MenuInfo control or mouse) funnels through RestoreDefaults. + // Without it the Mods tab still works. Reset just won't restore mod defaults. const auto restore_defaults = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::RestoreDefaults"]; if (restore_defaults) { diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 14d1bb6..41090da 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -10,22 +10,19 @@ namespace big::mod_settings void register_hooks(); void bind_config_api(sol::state_view& state, sol::table& lua_ext); - // A user-facing string an author may write in config.lua either plainly ("Enable feature") or as a - // localization table keyed by the game's language folder codes ({ en = "...", de = "...", - // ["zh-TW"] = "..." }). Stored as language-code -> text, with a plain string kept under the empty - // key. The settings menu resolves it to the current game language at render time (see - // resolve_localized), falling back to English then any entry. + // A user-facing string an author may write in config.lua either plainly ("Enable feature") or as a localization + // table keyed by the game's language folder codes ({ en = "...", de = "...", ["zh-TW"] = "..." }). Stored as + // language-code -> text, with a plain string kept under the empty key. The settings menu resolves it to the current + // game language at render time (see resolve_localized), falling back to English then any entry. using localized_text = std::map; - // When a setting may be changed, relative to whether a save is loaded. The Lua state is recreated - // when a save is loaded from the main menu, so init-time changes (GameData edits, function patches) - // only take effect if made before that point, while some settings only apply to a live run. The - // settings menu greys a row (read-only, with a note) when the current context does not match: - // - any: editable anywhere (default; live-read settings). - // - main_menu: only from the main menu (greyed while a save is loaded). Forced for a mod's master - // "enabled" toggle and for any restart_required setting. - // - in_save: only while a save is loaded (greyed at the main menu). - // Authors declare this per setting via `editable_context = "main_menu" | "in_save" | "any"`. + // When a setting may be changed, relative to whether a save is loaded. The Lua state is recreated when a save is + // loaded from the main menu, so init-time changes (GameData edits, function patches) only take effect if made + // before that point, while some settings only apply to a live run. The settings menu greys a row (read-only, with a + // note) when the current context does not match: any: editable anywhere (default live-read settings). main_menu: + // only from the main menu (greyed while a save is loaded). Forced for a mod's master "enabled" toggle and for any + // restart_required setting. in_save: only while a save is loaded (greyed at the main menu). Authors declare this + // per setting via `editable_context = "main_menu" | "in_save" | "any"`. enum class editable_context { any, @@ -33,11 +30,11 @@ namespace big::mod_settings in_save, }; - // Author-declared metadata for a single setting, extracted from its config.lua description - // table by rom.mod_settings.load and consulted by the settings menu. Only settings whose - // description is a rich table have an entry; the rest fall back to type-based rendering. Every - // field is an author-only input that cannot be inferred from the config value; the widget kind - // itself is inferred from the value and `values`. All fields are optional (see the has_* flags). + // Author-declared metadata for a single setting, extracted from its config.lua description table by + // rom.mod_settings.load. Consulted by the settings menu. Only settings whose description is a rich table have an + // entry. The rest fall back to type-based rendering. Every field is an author-only input that cannot be inferred + // from the config value. The widget kind itself is inferred from the value and `values`. All fields are optional + // (see the has_* flags). struct setting_metadata { localized_text name; // display-name override (empty -> prettified key) @@ -50,52 +47,95 @@ namespace big::mod_settings bool has_step = false; double step = 0.0; - // Enum options: serialized option values and parallel display labels (labels default to - // the values when omitted). Serialized form matches the config entry's serialization; each - // label may be localized. + // Enum options: serialized option values and parallel display labels (labels default to the values when + // omitted). Serialized form matches the config entry's serialization. Each label may be localized. std::vector values; std::vector labels; bool has_order = false; - double order = 0.0; // author-declared sort key (lower first); unset -> map order + double order = 0.0; // author-declared sort key (lower first), unset -> map order bool hidden = false; // author asked to omit this row entirely + bool disabled = false; // render greyed and non-interactive but still visible (may be dynamic) bool restart_required = false; // change only takes effect after a game restart bool freetext = false; // force a bounded number to freetext entry (not the stepper) - // When this setting may be changed relative to a loaded save (see editable_context). Default - // `any`; forced to `main_menu` for the master "enabled" toggle and for restart_required settings. + // When this setting may be changed relative to a loaded save (see editable_context). Default `any`. Forced to + // `main_menu` for the master "enabled" toggle and for restart_required settings. editable_context context = editable_context::any; - // Number-display options (mainly for the slider). is_percentage shows a 0..1 value as 0..100 and - // appends "%"; show_as_percentage only appends "%" (no scaling). Setting show_as_percentage in - // addition to is_percentage is a no-op. The stored config value is never modified by either. + + // True if any field in this setting's config.lua description is a Lua function (a dynamic field, e.g. `max = + // function() ... end`). Such fields are skipped at load and re-evaluated at render via + // resolve_setting_metadata, so the menu reflects the current game state. + bool has_dynamic = false; + + // Number-display options (mainly for the slider) is_percentage shows a 0..1 value as 0..100 and appends "%" + // show_as_percentage only appends "%" without scaling. Setting show_as_percentage in addition to is_percentage + // is a no-op. The stored config value is never modified by either. bool show_as_percentage = false; bool is_percentage = false; }; - // True if a mod author declared this setting as requiring a game restart to take effect - // (via `restart_required = true` in the setting's config.lua description). Populated by - // rom.mod_settings.load; consulted by the settings menu when a value changes. + // True if a mod author declared this setting as requiring a game restart to take effect (via `restart_required = + // true` in the setting's config.lua description). Populated by rom.mod_settings.load. Consulted by the settings + // menu when a value changes. bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key); - // Returns the author-declared metadata for a setting, or std::nullopt when the setting has no - // rich metadata table (in which case the menu renders it with type-based defaults). + // Returns the author-declared metadata for a setting, or std::nullopt when the setting has no rich metadata table + // (in which case the menu renders it with type-based defaults). std::optional get_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); - // Rank of a setting's definition in its config.lua source (0 = first). Used to order rows that - // have no author-declared `order` in config-file order. Returns INT_MAX for keys not bound via - // rom.mod_settings.load (e.g. Chalk-bound), so they fall back to the config map order. + // Like get_setting_metadata, but re-evaluates the setting's dynamic (Lua-function) description fields against the + // current game state, returning up-to-date values (slider bounds, enum options, hidden, display name, ...). Call + // this (on the game thread, while the Lua state is alive) when get_setting_metadata reports has_dynamic. The + // returned metadata never has has_dynamic set. Returns std::nullopt for settings with no stored description (e.g. + // Chalk-bound). + std::optional resolve_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); + + // A menu button declared in config.lua that runs a Lua callback instead of editing a config value: an `action = + // function() ... end` entry in the configDesc, which has no config counterpart. Placed among the setting rows by + // `order`. + struct action_info + { + std::string section; // config section the action lives in (drilldown level) + std::string key; // description key of the action + localized_text name; // button label (display_name, or the prettified key) + localized_text description; // help text shown while highlighted + bool has_order = false; // author-declared sort key present + double order = 0.0; + editable_context context = editable_context::any; // when the button is enabled (main-menu vs in-save) + bool disabled = false; // greyed and non-interactive (author-declared, may be dynamic) + bool has_dynamic = false; // name/description/order/disabled is a Lua function + }; + + // The action buttons declared directly in config `section` of mod `guid` (not recursing into child sections). + // Dynamic fields are resolved against the current game state (call on the game thread). + std::vector get_actions(const std::string& guid, const std::string& section); + + // Runs a config.lua action button's Lua callback protected, with errors logged. No-op if the guid / section / key + // does not resolve to an action. Call on the game thread while the Lua state is alive. + void invoke_action(const std::string& guid, const std::string& section, const std::string& key); + + // Rank of a setting's definition in its config.lua source (0 = first). Used to order rows that have no + // author-declared `order` in config-file order. Returns INT_MAX for keys not bound via rom.mod_settings.load (e.g. + // Chalk-bound), so they fall back to the config map order. int get_setting_appearance_order(const std::string& guid, const std::string& section, const std::string& key); - // Returns the config.lua default (serialized like the config entry's value) for a setting bound - // via rom.mod_settings.load, or std::nullopt for keys with no captured default. Used by the - // settings menu's Reset action to restore a setting to what config.lua declared. + // Returns the config.lua default (serialized like the config entry's value) for a setting bound via + // rom.mod_settings.load, or std::nullopt for keys with no captured default. Used by the settings menu's. Reset + // action to restore a setting to what config.lua declared. std::optional get_setting_default(const std::string& guid, const std::string& section, const std::string& key); - // True if a mod called rom.mod_settings.opt_out() from its Lua (keyed by the calling mod's guid, - // which matches a mod config's file stem). The settings menu still lists such a mod, but greys its - // row, blocks drilling into it, and shows an opt-out note in place of its description. Populated - // fresh each Lua-state init (opt_out re-runs with the mod's main.lua). + // True if a mod called rom.mod_settings.opt_out() from its Lua (keyed by the calling mod's guid, which matches a + // mod config's file stem). The settings menu still lists such a mod, but greys its row, blocks drilling into it, + // and shows an opt-out note in place of its description. Populated fresh each Lua-state init (opt_out re-runs with + // the mod's main.lua). bool mod_opted_out(const std::string& guid); + + // True while a setting change should notify its mod through an on_change callback: a native options screen is + // currently open and it was opened in-game (a save is loaded). Consulted by the config API so an on_change fires + // only for an edit made through the in-game options menu, which can be applied to the live run - never in the main + // menu, and never from a mod's own config write outside the menu. + bool on_change_callbacks_enabled(); } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp index a176cac..29ddb68 100644 --- a/src/hades2/mod_settings/sgg_gui.hpp +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -3,17 +3,16 @@ #include #include -// Minimal views over the native Hades II option-screen GUI objects, limited to the -// fields this feature reads or writes. Offsets are validated with static_assert against -// the current game build; the matching engine functions are resolved by PDB symbol name -// at runtime (see big::hades2_symbol_to_address). Only sgg::GUIComponent base fields and -// MiscSettingsScreen members are used, which stay stable across the button-layout changes -// that occur between game versions. +// Minimal views over the native Hades II option-screen GUI objects, limited to the fields this feature reads or writes. +// Offsets are validated with static_assert against the current game build. The matching engine functions are resolved +// by PDB symbol name at runtime (see big::hades2_symbol_to_address). Only sgg::GUIComponent base fields and +// MiscSettingsScreen members are used, which stay stable across the button-layout changes that occur between game +// versions. namespace big::mod_settings::sgg { - // sgg::Vectormath Vector2: two floats, 8 bytes. As a function argument this is an - // integer-class aggregate, so it is passed in a general-purpose register (RDX/R8/...), - // not an XMM register - the by-value POD typing below reproduces that ABI. + // sgg::Vectormath Vector2: two floats, 8 bytes. As a function argument this is an integer-class aggregate, so it is + // passed in a general-purpose register (RDX/R8/...), not an XMM register - the by-value POD typing below reproduces + // that ABI. struct Vec2 { float x; @@ -22,8 +21,8 @@ namespace big::mod_settings::sgg static_assert(sizeof(Vec2) == 8); - // eastl::vector stores three pointers (begin, end, capacity) followed by its - // allocator; begin/end are enough to iterate an existing vector. + // eastl::vector stores three pointers (begin, end, capacity) followed by its allocator begin/end are enough to + // iterate an existing vector. template struct eastl_vector { @@ -89,17 +88,21 @@ namespace big::mod_settings::sgg inline constexpr std::size_t gui_component_button_owner_offset = 0x5'A0; inline constexpr std::size_t gui_component_button_size = 0x5'B0; - // Byte offset of GUIComponentButton::mDisplayNameId (sgg::HashGuid: a 32-bit interned-string id). - // The engine derives a button's visible label from this id: GUIComponentButton::UseDefaultText - // resolves the id back to its interned string, looks that up in the localized text data, and sets - // the label from the result (falling back to the raw string on a miss). UseDefaultText re-runs on - // every localization pass, including a live language change, so this id - not any string handed to - // SetDisplayName - is what determines the persistent label. + // Byte offset of GUIComponentButton::mSelectable (bool). GUIComponentButton::IsSelectable returns it. + // MenuScreen::SetMouseOver skips a component whose IsSelectable is false, so clearing it makes a button + // non-hoverable and non-selectable (used to fully disable a greyed action button). + inline constexpr std::size_t gui_component_button_selectable_offset = 0x5'51; + + // Byte offset of GUIComponentButton::mDisplayNameId (sgg::HashGuid: a 32-bit interned-string id). The engine + // derives a button's visible label from this id:. GUIComponentButton::UseDefaultText resolves the id back to its + // interned string, looks that up in the localized text data, and sets the label from the result (falling back to + // the raw string on a miss). UseDefaultText re-runs on every localization pass, including a live language change, + // so this id - not any string handed to SetDisplayName - is what determines the persistent label. inline constexpr std::size_t gui_component_button_display_name_id_offset = 0x1'68; - // sgg::MenuScreen, the base of MiscSettingsScreen. mComponents owns every live widget - // that is drawn and hit-tested; freed components are dropped from it. mAnchor is the - // base location the engine gives freshly created option components. + // sgg::MenuScreen, the base of MiscSettingsScreen mComponents owns every live widget that is drawn and hit-tested + // freed components are dropped from it mAnchor is the base location the engine gives freshly created option + // components. struct MenuScreen { char m_pad_anchor[0x50]; @@ -120,10 +123,9 @@ namespace big::mod_settings::sgg static_assert(offsetof(MenuScreen, m_cancel_button) == 0x1'A8); static_assert(offsetof(MenuScreen, m_selected_component) == 0x1'B0); - // sgg::MiscSettingsScreen, the native tabbed options screen. The category buttons are - // laid out contiguously from +0x388 (Gameplay) to +0x3F8 (Debug); the non-user - // categories such as Editor follow the eight user-facing ones. mOptions holds the - // current category's option components. + // sgg::MiscSettingsScreen, the native tabbed options screen. The category buttons are laid out contiguously from + // +0x388 (Gameplay) to +0x3F8 (Debug). The non-user categories such as Editor follow the eight user-facing ones + // mOptions holds the current category's option components. struct MiscSettingsScreen { char m_pad_psi[0x3'44]; From cc72391527dbd90cd28faf7531c977c5746e7d45 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:56:07 +0100 Subject: [PATCH 039/100] Fix action-button hitbox width and prompt refresh on drag release --- src/hades2/mod_settings/mod_settings.cpp | 63 +++++++++++++++--------- 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 524a6a2..fe1e236 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -192,7 +192,7 @@ namespace big::mod_settings static constexpr std::size_t slider_fill_offset = 0x5'78; // mFill (GUIComponentImage*, progress fill) static constexpr std::size_t slider_label_offset = 0x5'90; // mLabel (GUIComponentTextBox*, left label) static constexpr std::size_t slider_value_text_offset = 0x5'98; // mValueTextBox (GUIComponentTextBox*, right value) - static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) + static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) // Scalar deleting destructor slot in the GUIComponent vtable. Called with flags=0 it destructs and frees any owned // sub-components without the final operator delete, so we then. _aligned_free. @@ -226,6 +226,7 @@ namespace big::mod_settings using teleport_cursor_fn = void (*)(void* menu_screen, GUIComponent* component); using component_focused_fn = void (*)(void* misc_settings_screen, GUIComponent* component); using input_get_state_fn = std::uint32_t (*)(void* input_handler, const void* remappable_control); + using mouse_button_down_fn = bool (*)(void* input_handler); // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -272,6 +273,7 @@ namespace big::mod_settings static component_focused_fn g_component_focused = nullptr; // focuses a row so it receives stick input + green static input_get_state_fn g_input_get_state = nullptr; // reads a remappable control's per-frame state + static mouse_button_down_fn g_mouse_button_down = nullptr; // true while a mouse button is held (active drag detect) static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A / Enter) static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) @@ -962,7 +964,7 @@ namespace big::mod_settings set_sso_string(row_bytes + gui_component_name_offset, "CategoryOptionsButton"); g_apply_data(reinterpret_cast(screen), row); - // Stretch. The box only enough to fit a label wider than the native box (see button_label_*), so short labels + // Stretch the box only enough to fit a label wider than the native box (see button_label_*), so short labels // keep the clean native box. Drawn box width = native * mScale * box_scale_x. const float box_scale_x = std::max(1.0f, (measure_width(label) + button_label_padding) / button_label_capacity); @@ -974,14 +976,17 @@ namespace big::mod_settings *reinterpret_cast(def + def_offset_y) = 0.0f; // drop the template's built-in vertical offset *reinterpret_cast(def + def_scale) = button_scale; // shrink slightly for top/bottom breathing room - // Match the hit-test rect to the visible (stretched) box so hover/click line up with what is drawn. - *reinterpret_cast(def + def_width) = button_graphic_native_width * button_scale * box_scale_x; + // The hover/click rect is GetArea = mCustomWidth * mScale@0x38 * mScaleX@0x114. The drawn box already reflects + // button_scale and mScaleX@0x114 carries box_scale_x below, so mCustomWidth is the plain native width. Baking + // button_scale in here as well applies it twice and pulls the hit rect inside the drawn box. This native width + // also seeds the label's copied def width, which is widened back below so the label does not wrap. + *reinterpret_cast(def + def_width) = button_graphic_native_width; *reinterpret_cast(def + def_height) = 58.0f; // Momentary selection: the CategoryOptionsButton template keeps a button selected (its highlight lit) after a // mouse-off - correct for the category tabs, but an action button should not stay lit like a selected tab once - // clicked mDeselectOnMouseOff makes the highlight clear when the cursor leaves (the highlight still shows while - // hovered), so the action button reads as momentary. + // clicked. mDeselectOnMouseOff makes the highlight clear when the cursor leaves (the highlight still shows + // while hovered), so the action button reads as momentary. *reinterpret_cast(def + def_deselect_on_mouse_off) = true; if (disabled) @@ -994,12 +999,20 @@ namespace big::mod_settings g_setup_component(row, row_bytes + component_data_offset); } + // SetupComponent copied the button def (with the native mCustomWidth used for the hit rect) into the child + // label, whose own def mWidth drives where the text wraps. For a stretched box widen the label's copy to the + // full visible width so a long label stays on one line instead of wrapping at the native width. + if (auto* label_box = *reinterpret_cast(row_bytes + button_label_offset)) + { + *reinterpret_cast(label_box + component_def_offset + def_width) = button_graphic_native_width * box_scale_x; + } + if (g_set_label) { g_set_label(row, label); } - // Widen. The box graphic to box_scale_x. The box is a single-frame animation reached via mAnim enabling + // Widen the box graphic to box_scale_x. The box is a single-frame animation reached via mAnim enabling // mScaleModifierOnlyX makes GUIComponentAnimation::Draw honour the anim's own def mScaleX (horizontal-only), // which the button otherwise leaves at a uniform scale. The selection highlight (mSelectedTexture, drawn as an // overlay) is instead scaled by the BUTTON's own def mScaleX/mScaleY (the button's Drawable), independent of @@ -1143,8 +1156,8 @@ namespace big::mod_settings } char* nb_bytes = reinterpret_cast(nb); - // Name. The box and its sub-components so ApplyDataToComponent applies the matching sjson templates (its - // virtual. ApplyDataToName routes each def by the sub-component's mName). + // Name the box and its sub-components so ApplyDataToComponent applies the matching sjson templates (its + // virtual ApplyDataToName routes each def by the sub-component's mName). set_sso_string(nb_bytes + gui_component_name_offset, "OptionNumBox"); if (void* value_tb = *reinterpret_cast(nb_bytes + numbox_value_text_offset)) { @@ -2598,18 +2611,24 @@ namespace big::mod_settings return nullptr; } - // True while the user is still interacting with one of our rows: the entered component (keyboard or controller - // adjusting a slider/enum) or the moused-over component (mouse hovering or dragging one). The numeric-change - // dynamic refresh holds its rebuild until this is false, so the rebuild never frees a row that is being adjusted - // (which would drop keyboard focus or interrupt a mouse drag). - static bool interacting_with_row(MiscSettingsScreen* screen) + // True while the user is still actively adjusting one of our rows: the entered component (keyboard or controller + // adjusting a slider/enum), or a mouse drag (a mouse button held over one of our rows). The numeric-change dynamic + // refresh holds its rebuild until this is false, so the rebuild never frees a row mid-adjust (which would drop + // keyboard focus or interrupt a mouse drag). A mere mouse hover does not hold, so the refresh fires promptly once a + // drag is released even while the pointer still rests on the row. If the mouse-down probe is unavailable, any hover + // holds instead, so a drag is never interrupted. + static bool interacting_with_row(MiscSettingsScreen* screen, void* input) { if (screen->m_component_focused && find_row(screen->m_component_focused)) { return true; } auto* menu = reinterpret_cast(screen); - return menu->m_mouse_over_component && find_row(menu->m_mouse_over_component); + if (!menu->m_mouse_over_component || !find_row(menu->m_mouse_over_component)) + { + return false; + } + return g_mouse_button_down ? g_mouse_button_down(input) : true; } // The component whose description was last written to the description box. The box is only updated when the @@ -2681,7 +2700,7 @@ namespace big::mod_settings g_set_label(button, text); } - // Retunes the options screen's bottom button prompts for the Mods tab per context, and hides the native. Reset + // Retunes the options screen's bottom button prompts for the Mods tab per context, and hides the native Reset // prompt where it must not apply. Called every frame from the Update hook (after the original, which sets the // native prompts on focus/hover/category events). Off the Mods tab it only clears our caches and leaves the native // prompts untouched. @@ -3770,16 +3789,15 @@ namespace big::mod_settings // A slider drag, number-box adjust, or freetext commit in a view that has dynamic (function) rows re-evaluates // those rows (e.g. an apply button enabling itself when a value changes). The rebuild frees and recreates the // rows, so it is deferred two ways: a short debounce absorbs the per-frame slider hook, and while the user is - // still interacting with a row (adjusting it with keyboard/controller, or hovering/dragging it with the mouse) - // the rebuild is HELD until they move off it - otherwise it would free the focused slider mid-adjust or - // interrupt a mouse drag. + // still actively adjusting a row (with keyboard/controller, or an in-progress mouse drag) the rebuild is HELD + // until they finish - otherwise it would free the focused slider mid-adjust or interrupt a mouse drag. if (g_dynamic_refresh_settle > 0.0f) { if (!on_mods_tab) { g_dynamic_refresh_settle = 0.0f; } - else if (interacting_with_row(screen)) + else if (interacting_with_row(screen, input)) { g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; // hold until they leave the row } @@ -3909,8 +3927,8 @@ namespace big::mod_settings // on-screen "Exit" button) converges here (MiscSettingsScreen::ExitScreen, vtable slot 7), before any fade/teardown // and while mScreenManager is valid. If a restart is required, show the native message box and DO NOT run the // original (veto the close): The box is modal over the still-open options screen and its button closes the game. A - // restart-required change must not be cancellable (that would require undoing the change), so the restart is. - // Forced. If the native dialog cannot be built, the change is already saved to the mod's config (it applies on the + // restart-required change must not be cancellable (that would require undoing the change), so the restart is + // forced. If the native dialog cannot be built, the change is already saved to the mod's config (it applies on the // next manual restart), so we just let the screen close normally. static void hook_MiscSettingsScreen_ExitScreen(void* self) { @@ -4039,6 +4057,7 @@ namespace big::mod_settings // RVA-relative (resolved below). Optional - their absence only degrades controller support, not the tab. g_component_focused = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ComponentFocused"].as_func(); g_input_get_state = big::hades2_symbol_to_address["sgg::InputHandler::GetState"].as_func(); + g_mouse_button_down = big::hades2_symbol_to_address["sgg::InputHandler::IsLeftOrRightMouseButtonDown"].as_func(); // Native-settings flush before a forced restart. SaveProfile. SaveProfile serializes the active profile // (language, volumes, graphics, gameplay/interface toggles) to disk. ACTIVE_PROFILE is the profile-name string From 197657c099f11ba0bb2006f152956b31bd4db033 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:02:40 +0100 Subject: [PATCH 040/100] Fix keyboard/controller slider adjustment not being discrete --- src/hades2/mod_settings/mod_settings.cpp | 79 +++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index fe1e236..fde0c0c 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -227,6 +227,7 @@ namespace big::mod_settings using component_focused_fn = void (*)(void* misc_settings_screen, GUIComponent* component); using input_get_state_fn = std::uint32_t (*)(void* input_handler, const void* remappable_control); using mouse_button_down_fn = bool (*)(void* input_handler); + using input_dir_pressed_fn = bool (*)(void* input_handler); // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -274,8 +275,10 @@ namespace big::mod_settings static component_focused_fn g_component_focused = nullptr; // focuses a row so it receives stick input + green static input_get_state_fn g_input_get_state = nullptr; // reads a remappable control's per-frame state static mouse_button_down_fn g_mouse_button_down = nullptr; // true while a mouse button is held (active drag detect) - static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) - static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A / Enter) + static input_dir_pressed_fn g_input_was_left_pressed = nullptr; // left / decrease press edge (dpad, arrow, stick) + static input_dir_pressed_fn g_input_was_right_pressed = nullptr; // right / increase press edge + static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) + static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A / Enter) static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) static void* g_active_profile = nullptr; // &sgg::ProfileManager::ACTIVE_PROFILE @@ -3624,6 +3627,64 @@ namespace big::mod_settings format_setting_display(v, row->show_as_percentage, row->is_percentage, step_v).c_str()); } + // Moves a slider row one grid step (dir -1 or +1) from its current snapped value, clamped to [min, max], and writes + // the exact grid fraction through SetFraction with notify so the SetFraction hook stores the value and repaints the + // value text. The stored value is already on the grid, so rounding the current index is just a safety net. + static void step_slider_row(void* slider, PanelRow* row, int dir) + { + if (!g_slider_set_fraction || !row->entry) + { + return; + } + const double min_v = row->stepper_min; + const double max_v = row->stepper_max; + const double step_v = row->stepper_step > 0.0 ? row->stepper_step : 1.0; + const double range = max_v - min_v; + if (range <= 0.0) + { + return; + } + const double idx = std::round((row->entry->get_value_base() - min_v) / step_v); + double v = min_v + (idx + dir) * step_v; + if (v < min_v) + { + v = min_v; + } + else if (v > max_v) + { + v = max_v; + } + g_slider_set_fraction(slider, static_cast((v - min_v) / range), true); + } + + // Discrete keyboard/controller stepping for our slider rows. The native GUIComponentSlider::HandleInput slides + // mFraction continuously (axisSum * speed * dt behind a 0.5 dead-zone, summing dpad, arrow keys, WASD and the left + // stick), so a small tap can land back on the same snapped value. For our rows under keyboard/controller (UseMouse + // off) we bypass that path and move exactly one step on each left/right press edge, so every input changes the + // value by at least one step and a held direction cannot creep between steps. Mouse drag (UseMouse on) and every + // native slider keep the original continuous behaviour. If the edge probes are missing the whole path is skipped at + // install time, so this only runs when both are available. + static bool hook_GUIComponentSlider_HandleInput(void* self, void* input, float dt) + { + if (self && !(g_use_mouse && *g_use_mouse)) + { + PanelRow* row = find_row(reinterpret_cast(self)); + if (row && row->is_slider && !row->disabled && row->entry) + { + if (g_input_was_right_pressed(input)) + { + step_slider_row(self, row, 1); + } + else if (g_input_was_left_pressed(input)) + { + step_slider_row(self, row, -1); + } + return true; // own the slider's keyboard/controller input so the native continuous slide never runs + } + } + return big::g_hooking->get_original()(self, input, dt); + } + // Button-click hook GUIComponentButton overrides. GUIComponent::OnClicked (vtable slot +0x100, the engine's // terminal-click), so this is where our button rows' clicks land. For our rows the engine returns false (they have // no bound activate function) but still plays the press sound, so we must match the row regardless of the return @@ -4059,6 +4120,12 @@ namespace big::mod_settings g_input_get_state = big::hades2_symbol_to_address["sgg::InputHandler::GetState"].as_func(); g_mouse_button_down = big::hades2_symbol_to_address["sgg::InputHandler::IsLeftOrRightMouseButtonDown"].as_func(); + // Left/right press edges (dpad, arrow keys and a left-stick flick fold into these), read to move our discrete + // slider rows one step per press instead of the native continuous slide. Optional - without them the sliders + // keep the native continuous keyboard/controller behaviour. + g_input_was_left_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasLeftPressed"].as_func(); + g_input_was_right_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasRightPressed"].as_func(); + // Native-settings flush before a forced restart. SaveProfile. SaveProfile serializes the active profile // (language, volumes, graphics, gameplay/interface toggles) to disk. ACTIVE_PROFILE is the profile-name string // it takes. Both are named PDB globals/functions. Optional - if either is missing we simply skip the flush (the @@ -4140,6 +4207,14 @@ namespace big::mod_settings if (slider_set_fraction) { static auto set_fraction_hook = hooking::detour_hook_helper::add_queue("sgg::GUIComponentSlider::SetFraction", slider_set_fraction); + + // Discrete keyboard/controller stepping needs the left/right edge probes; without them our slider rows keep + // the native continuous slide, so only install the input override when both resolved. + const auto slider_handle_input = big::hades2_symbol_to_address["sgg::GUIComponentSlider::HandleInput"]; + if (slider_handle_input && g_input_was_left_pressed && g_input_was_right_pressed) + { + static auto slider_handle_input_hook = hooking::detour_hook_helper::add_queue("sgg::GUIComponentSlider::HandleInput", slider_handle_input); + } } static auto update_hook = hooking::detour_hook_helper::add_queue("sgg::MiscSettingsScreen::Update", update); From a03680f8ec1553304a9308b605ac42cc5a9b9312 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:58:29 +0100 Subject: [PATCH 041/100] Fix one-frame prompt/description/highlight blink on setting rebuilds --- src/hades2/mod_settings/mod_settings.cpp | 105 +++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index fde0c0c..8bf21c7 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -436,6 +436,28 @@ namespace big::mod_settings static std::string g_pending_section; static bool g_nav_reset_to_top = false; // Reset action: force a top (non-instant) rebuild next apply_nav. + // Identifies a panel row by its stable fields (kind + owning mod + section + key) so it can be matched to the + // equivalent freshly built row after a rebuild frees every component. + struct RowIdentity + { + bool valid = false; + RowKind kind; + std::string stem; + std::string section; + std::string key; + }; + + // The clicked row to hold as hovered/selected across a click-triggered instant rebuild, captured in the OnClicked + // hook. A rebuild frees every component and the native hover pass re-resolves the cursor a frame later, so this is + // re-asserted for a few frames (see reassert_keep_active_row) to steady the prompt, description and highlight. + static RowIdentity g_keep_active_row; + + // Frames to re-assert the clicked row as hovered/selected after a click-triggered instant rebuild. The native hover + // pass runs the frame after the rebuild and can transiently resolve the stationary cursor to a neighbouring row (or + // clear the bottom-prompt label), so the prompt, description and highlight blink for a frame unless we hold them. + static constexpr int keep_active_frame_count = 3; + static int g_keep_active_frames = 0; + // Seconds of input quiet after a numeric setting (slider / number-box) changes before the view is rebuilt to // re-evaluate its dynamic (Lua-function) rows - e.g. an apply button's dynamic `disabled`. static constexpr float dynamic_refresh_settle_seconds = 0.15f; @@ -2614,6 +2636,31 @@ namespace big::mod_settings return nullptr; } + // Builds a stable identity for a row so it can be re-found after a rebuild recreates the components. + static RowIdentity row_identity_of(const PanelRow& r) + { + return RowIdentity{true, r.kind, r.stem, r.target_section, r.setting_key}; + } + + // The freshly built row matching a captured identity, or null if it is gone or is no longer selectable. Used to put + // the hover and selection back on the equivalent new row after an instant rebuild. + static GUIComponent* find_row_by_identity(const RowIdentity& id) + { + if (!id.valid) + { + return nullptr; + } + for (const auto& row : g_rows) + { + GUIComponent* c = row.component; + if (c && row.kind == id.kind && row.stem == id.stem && row.target_section == id.section && row.setting_key == id.key && !row.disabled && c->m_is_useable && !c->m_hidden) + { + return c; + } + } + return nullptr; + } + // True while the user is still actively adjusting one of our rows: the entered component (keyboard or controller // adjusting a slider/enum), or a mouse drag (a mouse button held over one of our rows). The numeric-change dynamic // refresh holds its rebuild until this is false, so the rebuild never frees a row mid-adjust (which would drop @@ -2890,6 +2937,34 @@ namespace big::mod_settings return (g_input_get_state(input, control) & 0x4u) != 0; } + // Holds the clicked row (captured before a click-triggered instant rebuild) as the moused-over and selected + // component, and forces our bottom prompt and description to re-apply, for a few frames after the rebuild. The + // native hover pass runs in HandleInput (after this Update) and, over the freshly laid-out rows, can transiently + // resolve the stationary cursor to a neighbouring row or clear the prompt label, so re-asserting here each frame + // keeps the prompt, description and highlight steady on the clicked row instead of blinking onto a neighbour or to + // a bare glyph. Mouse mode only - keyboard/controller focus is restored in build_panel. + static void reassert_keep_active_row(MiscSettingsScreen* screen) + { + if (!(g_use_mouse && *g_use_mouse)) + { + return; + } + GUIComponent* keep = find_row_by_identity(g_keep_active_row); + if (!keep) + { + return; + } + auto* menu = reinterpret_cast(screen); + menu->m_mouse_over_component = keep; + menu->m_selected_component = keep; + + // Clear the prompt caches and the last-description marker so this frame's sync re-applies our label and text, + // overriding a native clear on the rebuild frame. + g_prompt_confirm_label.clear(); + g_prompt_cancel_label.clear(); + g_last_description_component = nullptr; + } + static void build_panel(MiscSettingsScreen* screen, bool instant = false) { // A rebuild frees and recreates the row components, so the cached highlighted-row pointer is stale force the @@ -3023,6 +3098,21 @@ namespace big::mod_settings } } + // Arm a short re-assert window after a click-triggered instant rebuild (a toggle or an action). A rebuild frees + // the row under the cursor, and over the next frame the native hover pass can transiently resolve the + // stationary cursor to a neighbouring row or clear our prompt label, blinking the prompt, description and + // highlight. The Update hook re-asserts the clicked row over these frames (see reassert_keep_active_row). Only + // meaningful in mouse mode - keyboard/controller focus is restored above. + if (instant && g_keep_active_row.valid && g_use_mouse && *g_use_mouse) + { + g_keep_active_frames = keep_active_frame_count; + } + else + { + g_keep_active_row.valid = false; + g_keep_active_frames = 0; + } + if (restoring) { g_has_pending_restore = false; @@ -3783,6 +3873,7 @@ namespace big::mod_settings g_pending_stem = matched_row.stem; g_pending_section = g_view_section; g_nav_pending = true; + g_keep_active_row = row_identity_of(matched_row); } } else if (entry) @@ -3801,6 +3892,7 @@ namespace big::mod_settings g_pending_stem = matched_row.stem; g_pending_section = g_view_section; g_nav_pending = true; + g_keep_active_row = row_identity_of(matched_row); break; } } @@ -3904,6 +3996,19 @@ namespace big::mod_settings g_nav_pending = false; } + // For a few frames after a click-triggered rebuild, pin the clicked row as hovered/selected and re-apply our + // prompt/description over the native hover pass, which settles over the new layout a frame later and would + // otherwise blink the prompt, description or highlight onto a neighbouring row. Runs before the original Update + // (which reads mMouseOverComponent for the description) so this frame is already correct. + if (g_keep_active_frames > 0 && on_mods_tab) + { + reassert_keep_active_row(screen); + if (--g_keep_active_frames == 0) + { + g_keep_active_row.valid = false; + } + } + void* result = big::g_hooking->get_original()(self, dt, input); // The original just laid out the key rows for this frame mirror the value columns onto them so the right column From 810d501428318e145fa88ced75a63fe127ba3eb0 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:13:52 +0100 Subject: [PATCH 042/100] Add keyboard/controller cross-page scrolling in the Mods tab --- src/hades2/mod_settings/mod_settings.cpp | 95 ++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 8bf21c7..76623be 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -228,6 +228,7 @@ namespace big::mod_settings using input_get_state_fn = std::uint32_t (*)(void* input_handler, const void* remappable_control); using mouse_button_down_fn = bool (*)(void* input_handler); using input_dir_pressed_fn = bool (*)(void* input_handler); + using scroll_page_fn = void (*)(void* misc_settings_screen); // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -277,6 +278,10 @@ namespace big::mod_settings static mouse_button_down_fn g_mouse_button_down = nullptr; // true while a mouse button is held (active drag detect) static input_dir_pressed_fn g_input_was_left_pressed = nullptr; // left / decrease press edge (dpad, arrow, stick) static input_dir_pressed_fn g_input_was_right_pressed = nullptr; // right / increase press edge + static input_dir_pressed_fn g_input_is_down_pressed = nullptr; // down held (control binding, gamepad, arrow key) + static input_dir_pressed_fn g_input_is_up_pressed = nullptr; // up held (control binding, gamepad, arrow key) + static scroll_page_fn g_scroll_down = nullptr; // sgg::MiscSettingsScreen::ScrollDown (advance page) + static scroll_page_fn g_scroll_up = nullptr; // sgg::MiscSettingsScreen::ScrollUp (previous page) static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A / Enter) static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) @@ -458,6 +463,17 @@ namespace big::mod_settings static constexpr int keep_active_frame_count = 3; static int g_keep_active_frames = 0; + // Keyboard/controller cross-page scrolling state. The native directional nav cannot cross a page boundary on its + // own (see the HandleInput hook), so a fresh DOWN/UP press while the highlight is already on the page-edge row + // pages to the adjacent page. g_prev_* track the previous frame's held state so that press is read as an edge + // (paging only on a new press, so navigating onto the edge row stops there rather than paging through it). g_page_ + // swallow_* hold the still-pressed direction after a page until it is released, so the native nav does not then + // walk the new page and shift the selection off the landing (edge) row. + static bool g_prev_down_held = false; + static bool g_prev_up_held = false; + static bool g_page_swallow_down = false; + static bool g_page_swallow_up = false; + // Seconds of input quiet after a numeric setting (slider / number-box) changes before the view is rebuilt to // re-evaluate its dynamic (Lua-function) rows - e.g. an apply button's dynamic `disabled`. static constexpr float dynamic_refresh_settle_seconds = 0.15f; @@ -4084,6 +4100,74 @@ namespace big::mod_settings request_back_nav(); return true; } + + // Cross-page scrolling. The native directional nav is a spatial nearest-in-cone search that excludes + // off-page rows (their fade target is 0), so DOWN on the last on-page row finds nothing below and cannot + // advance the page - only the mouse wheel and the on-screen arrows do. When the highlight is already on the + // page-edge row and that direction is freshly pressed (an edge, not a hold), drive the engine's own pager + // (ScrollDown/ScrollUp): it self-guards the bounds, refreshes the layout, and moves the selection onto the + // adjacent page's edge row. Gating on the press edge means navigating onto the edge row just stops there; a + // separate press is needed to page past it. + const bool down_held = g_scroll_down && g_input_is_down_pressed && g_input_is_down_pressed(input); + const bool up_held = g_scroll_up && g_input_is_up_pressed && g_input_is_up_pressed(input); + const bool down_edge = down_held && !g_prev_down_held; + const bool up_edge = up_held && !g_prev_up_held; + g_prev_down_held = down_held; + g_prev_up_held = up_held; + + // A page press stays held for several frames. Swallow it until release so the native nav does not walk the + // new page and shift the selection off the row the pager landed on (the new page's edge row). + if (!down_held) + { + g_page_swallow_down = false; + } + if (!up_held) + { + g_page_swallow_up = false; + } + if ((down_held && g_page_swallow_down) || (up_held && g_page_swallow_up)) + { + return true; + } + + if ((down_edge || up_edge) && g_rows.size() > rows_per_page) + { + GUIComponent* anchor = menu->m_selected_component ? menu->m_selected_component : menu->m_mouse_over_component; + std::size_t idx = g_rows.size(); + for (std::size_t i = 0; i < g_rows.size(); ++i) + { + if (g_rows[i].component == anchor) + { + idx = i; + break; + } + } + if (idx < g_rows.size()) + { + const std::size_t page_start = screen->m_page_start_index; + if (down_edge && idx == page_start + rows_per_page - 1 && page_start + rows_per_page < g_rows.size()) + { + g_scroll_down(screen); + g_page_swallow_down = true; + return true; + } + if (up_edge && idx == page_start && page_start > 0) + { + g_scroll_up(screen); + g_page_swallow_up = true; + return true; + } + } + } + } + else + { + // Reset the cross-page held state whenever we are not in keyboard/controller option navigation, so + // re-entering does not read a stale held-direction edge or swallow. + g_prev_down_held = false; + g_prev_up_held = false; + g_page_swallow_down = false; + g_page_swallow_up = false; } return big::g_hooking->get_original()(self, input, x); @@ -4231,6 +4315,17 @@ namespace big::mod_settings g_input_was_left_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasLeftPressed"].as_func(); g_input_was_right_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasRightPressed"].as_func(); + // Down/up held probes (control binding, gamepad dpad/stick and raw arrow keys fold into these) plus the native + // pagers. The keyboard/controller directional nav is a spatial search that excludes off-page rows, so it cannot + // cross a page boundary on its own; when the highlighted row is at a page edge and the matching direction is + // freshly pressed we drive ScrollDown/ScrollUp (each self-guards the bounds and moves the selection onto the + // new page). Optional - without them the Mods tab still works, only keyboard/controller cross-page scrolling is + // lost. + g_input_is_down_pressed = big::hades2_symbol_to_address["sgg::InputHandler::IsDownPressed"].as_func(); + g_input_is_up_pressed = big::hades2_symbol_to_address["sgg::InputHandler::IsUpPressed"].as_func(); + g_scroll_down = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ScrollDown"].as_func(); + g_scroll_up = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ScrollUp"].as_func(); + // Native-settings flush before a forced restart. SaveProfile. SaveProfile serializes the active profile // (language, volumes, graphics, gameplay/interface toggles) to disk. ACTIVE_PROFILE is the profile-name string // it takes. Both are named PDB globals/functions. Optional - if either is missing we simply skip the flush (the From 1e609c45af9469446f68fbdf9815f47133629be4 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:30:49 +0100 Subject: [PATCH 043/100] Switch Mods-tab cross-page scrolling to the native scroll arrows --- src/hades2/mod_settings/mod_settings.cpp | 144 ++++++++--------------- src/hades2/mod_settings/sgg_gui.hpp | 6 +- 2 files changed, 54 insertions(+), 96 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 76623be..5b717b7 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -165,6 +165,14 @@ namespace big::mod_settings static constexpr std::size_t component_def_scale_y_offset = 0x1'18; // mData.mDef.mScaleY (float) + // FreeFormSelectOffset is added to a component's location when the spatial keyboard/controller nav + // (SearchInDirection) evaluates it as a candidate. We use it to place the scroll arrows' eval point where the next + // or previous row would be, so the nav reaches an arrow at a page edge and its auto-activate fires the pager (see + // enable_arrow_keyboard_paging). + static constexpr std::size_t component_free_form_offset_x_offset = 0x1'54; // mFreeFormSelectOffsetX (float) + static constexpr std::size_t component_free_form_offset_y_offset = 0x1'58; // mFreeFormSelectOffsetY (float) + static constexpr std::size_t component_auto_activate_offset = 0x00'BC; // mAutoActivateWithGamepad (bool) + // The Button_Secondary sprite's native atlas width in px. The box draws at native * mScale * mScaleX. static constexpr float button_graphic_native_width = 350.0f; @@ -228,7 +236,6 @@ namespace big::mod_settings using input_get_state_fn = std::uint32_t (*)(void* input_handler, const void* remappable_control); using mouse_button_down_fn = bool (*)(void* input_handler); using input_dir_pressed_fn = bool (*)(void* input_handler); - using scroll_page_fn = void (*)(void* misc_settings_screen); // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -278,10 +285,6 @@ namespace big::mod_settings static mouse_button_down_fn g_mouse_button_down = nullptr; // true while a mouse button is held (active drag detect) static input_dir_pressed_fn g_input_was_left_pressed = nullptr; // left / decrease press edge (dpad, arrow, stick) static input_dir_pressed_fn g_input_was_right_pressed = nullptr; // right / increase press edge - static input_dir_pressed_fn g_input_is_down_pressed = nullptr; // down held (control binding, gamepad, arrow key) - static input_dir_pressed_fn g_input_is_up_pressed = nullptr; // up held (control binding, gamepad, arrow key) - static scroll_page_fn g_scroll_down = nullptr; // sgg::MiscSettingsScreen::ScrollDown (advance page) - static scroll_page_fn g_scroll_up = nullptr; // sgg::MiscSettingsScreen::ScrollUp (previous page) static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A / Enter) static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) @@ -463,17 +466,6 @@ namespace big::mod_settings static constexpr int keep_active_frame_count = 3; static int g_keep_active_frames = 0; - // Keyboard/controller cross-page scrolling state. The native directional nav cannot cross a page boundary on its - // own (see the HandleInput hook), so a fresh DOWN/UP press while the highlight is already on the page-edge row - // pages to the adjacent page. g_prev_* track the previous frame's held state so that press is read as an edge - // (paging only on a new press, so navigating onto the edge row stops there rather than paging through it). g_page_ - // swallow_* hold the still-pressed direction after a page until it is released, so the native nav does not then - // walk the new page and shift the selection off the landing (edge) row. - static bool g_prev_down_held = false; - static bool g_prev_up_held = false; - static bool g_page_swallow_down = false; - static bool g_page_swallow_up = false; - // Seconds of input quiet after a numeric setting (slider / number-box) changes before the view is rebuilt to // re-evaluate its dynamic (Lua-function) rows - e.g. an apply button's dynamic `disabled`. static constexpr float dynamic_refresh_settle_seconds = 0.15f; @@ -3916,6 +3908,46 @@ namespace big::mod_settings return result; } + // Points the native scroll arrows at the keyboard/controller nav so it can page. The spatial search + // (SearchInDirection) walks a ray from the selected row in the pressed direction and picks the nearest selectable + // component whose eval point (location + mFreeFormSelectOffset) is close to the ray. Off-page rows are unselectable + // (fade target 0), so from the last on-page row the ray finds nothing below and cannot advance. We make each arrow + // the target instead by placing its eval point exactly where the next (down) or previous (up) row would be: one + // row_pitch beyond the actual last / first visible row, at that row's location. Because the offset is set relative + // to the arrow's own location, any shared parent offset cancels, so the eval point tracks the real row position + // even after the action-button spacing shifts rows. The arrow's own auto-activate then fires ScrollDown/ScrollUp + // when the nav lands on it. Off the last/first page the arrow is hidden and unselectable, so this is inert there. + static void enable_arrow_keyboard_paging(MiscSettingsScreen* screen) + { + if (g_rows.empty()) + { + return; + } + const std::size_t first = screen->m_page_start_index; + if (first >= g_rows.size()) + { + return; + } + const std::size_t last = std::min(first + rows_per_page, g_rows.size()) - 1; + + const auto aim = [](GUIComponent* arrow, GUIComponent* row, float row_delta_y) + { + if (!arrow || !row) + { + return; + } + auto* bytes = reinterpret_cast(arrow); + *reinterpret_cast(bytes + component_free_form_offset_x_offset) = row->m_location_x - arrow->m_location_x; + *reinterpret_cast(bytes + component_free_form_offset_y_offset) = + (row->m_location_y + row_delta_y) - arrow->m_location_y; + *reinterpret_cast(bytes + component_auto_activate_offset) = true; + }; + + // Down arrow aims one row below the last visible row; up arrow one row above the first visible row. + aim(screen->m_down_arrow, g_rows[last].component, row_pitch); + aim(screen->m_up_arrow, g_rows[first].component, -row_pitch); + } + // Detour on the native scroll pass. The original lays every on-page row on the uniform grid (writing each row's // mLocation), so it is the point where our action-button spacing must be (re)applied: running it here, inside // MiscSettingsScreen::Update BEFORE the row hit-test in MenuScreen::Update, keeps the hover/click rects aligned @@ -3930,6 +3962,7 @@ namespace big::mod_settings if (on_mods_tab) { sync_button_spacing(screen); + enable_arrow_keyboard_paging(screen); } } @@ -4100,74 +4133,6 @@ namespace big::mod_settings request_back_nav(); return true; } - - // Cross-page scrolling. The native directional nav is a spatial nearest-in-cone search that excludes - // off-page rows (their fade target is 0), so DOWN on the last on-page row finds nothing below and cannot - // advance the page - only the mouse wheel and the on-screen arrows do. When the highlight is already on the - // page-edge row and that direction is freshly pressed (an edge, not a hold), drive the engine's own pager - // (ScrollDown/ScrollUp): it self-guards the bounds, refreshes the layout, and moves the selection onto the - // adjacent page's edge row. Gating on the press edge means navigating onto the edge row just stops there; a - // separate press is needed to page past it. - const bool down_held = g_scroll_down && g_input_is_down_pressed && g_input_is_down_pressed(input); - const bool up_held = g_scroll_up && g_input_is_up_pressed && g_input_is_up_pressed(input); - const bool down_edge = down_held && !g_prev_down_held; - const bool up_edge = up_held && !g_prev_up_held; - g_prev_down_held = down_held; - g_prev_up_held = up_held; - - // A page press stays held for several frames. Swallow it until release so the native nav does not walk the - // new page and shift the selection off the row the pager landed on (the new page's edge row). - if (!down_held) - { - g_page_swallow_down = false; - } - if (!up_held) - { - g_page_swallow_up = false; - } - if ((down_held && g_page_swallow_down) || (up_held && g_page_swallow_up)) - { - return true; - } - - if ((down_edge || up_edge) && g_rows.size() > rows_per_page) - { - GUIComponent* anchor = menu->m_selected_component ? menu->m_selected_component : menu->m_mouse_over_component; - std::size_t idx = g_rows.size(); - for (std::size_t i = 0; i < g_rows.size(); ++i) - { - if (g_rows[i].component == anchor) - { - idx = i; - break; - } - } - if (idx < g_rows.size()) - { - const std::size_t page_start = screen->m_page_start_index; - if (down_edge && idx == page_start + rows_per_page - 1 && page_start + rows_per_page < g_rows.size()) - { - g_scroll_down(screen); - g_page_swallow_down = true; - return true; - } - if (up_edge && idx == page_start && page_start > 0) - { - g_scroll_up(screen); - g_page_swallow_up = true; - return true; - } - } - } - } - else - { - // Reset the cross-page held state whenever we are not in keyboard/controller option navigation, so - // re-entering does not read a stale held-direction edge or swallow. - g_prev_down_held = false; - g_prev_up_held = false; - g_page_swallow_down = false; - g_page_swallow_up = false; } return big::g_hooking->get_original()(self, input, x); @@ -4315,17 +4280,6 @@ namespace big::mod_settings g_input_was_left_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasLeftPressed"].as_func(); g_input_was_right_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasRightPressed"].as_func(); - // Down/up held probes (control binding, gamepad dpad/stick and raw arrow keys fold into these) plus the native - // pagers. The keyboard/controller directional nav is a spatial search that excludes off-page rows, so it cannot - // cross a page boundary on its own; when the highlighted row is at a page edge and the matching direction is - // freshly pressed we drive ScrollDown/ScrollUp (each self-guards the bounds and moves the selection onto the - // new page). Optional - without them the Mods tab still works, only keyboard/controller cross-page scrolling is - // lost. - g_input_is_down_pressed = big::hades2_symbol_to_address["sgg::InputHandler::IsDownPressed"].as_func(); - g_input_is_up_pressed = big::hades2_symbol_to_address["sgg::InputHandler::IsUpPressed"].as_func(); - g_scroll_down = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ScrollDown"].as_func(); - g_scroll_up = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ScrollUp"].as_func(); - // Native-settings flush before a forced restart. SaveProfile. SaveProfile serializes the active profile // (language, volumes, graphics, gameplay/interface toggles) to disk. ACTIVE_PROFILE is the profile-name string // it takes. Both are named PDB globals/functions. Optional - if either is missing we simply skip the flush (the diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp index 29ddb68..3de7c33 100644 --- a/src/hades2/mod_settings/sgg_gui.hpp +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -145,7 +145,9 @@ namespace big::mod_settings::sgg bool m_category_focused; // +0x400 (false = option navigation, true = tab navigation) char m_pad_d[0x07]; // 0x401 .. 0x408 eastl_vector m_options; // +0x408 - char m_pad_e[0x20]; // 0x420 .. 0x440 + GUIComponent* m_up_arrow; // +0x420 (scroll-up arrow button) + GUIComponent* m_down_arrow; // +0x428 (scroll-down arrow button) + char m_pad_e[0x10]; // 0x430 .. 0x440 (scroll bar + tracker) GUIComponent* m_defaults_button; // +0x440 (bottom "Reset" prompt) char m_pad_f[0x18]; // 0x448 .. 0x460 GUIComponent* m_description_box; // +0x460 @@ -162,6 +164,8 @@ namespace big::mod_settings::sgg static_assert(offsetof(MiscSettingsScreen, m_debug_options_button) == 0x3'F8); static_assert(offsetof(MiscSettingsScreen, m_category_focused) == 0x4'00); static_assert(offsetof(MiscSettingsScreen, m_options) == 0x4'08); + static_assert(offsetof(MiscSettingsScreen, m_up_arrow) == 0x4'20); + static_assert(offsetof(MiscSettingsScreen, m_down_arrow) == 0x4'28); static_assert(offsetof(MiscSettingsScreen, m_defaults_button) == 0x4'40); static_assert(offsetof(MiscSettingsScreen, m_description_box) == 0x4'60); } // namespace big::mod_settings::sgg From 27cea66833072e2b21ba6a00398f6bed95caca4e Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:31:18 +0100 Subject: [PATCH 044/100] Fix slider row staying highlighted after a settings rebuild --- src/hades2/mod_settings/mod_settings.cpp | 63 ++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 5b717b7..22fd303 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -202,6 +202,16 @@ namespace big::mod_settings static constexpr std::size_t slider_value_text_offset = 0x5'98; // mValueTextBox (GUIComponentTextBox*, right value) static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) + // GUIComponentSlider has no Draw-time highlight gate (unlike GUIComponentButton, whose Draw re-derives its + // highlight from mForceSelected / owner->mSelectedComponent). Its "moused-over" look (green label + fill) and + // "focused" look (green value) are child state set by OnMouseOver / OnFocusOn and reverted only by OnMouseOff / + // OnFocusOff, so a stale flag survives across frames. mFocused is the slider's own bool the focus look tracks + // mUseSelectedTextColor is the green-text flag on a child GUIComponentTextBox (the left label / right value). + static constexpr std::size_t slider_focused_offset = 0x5'48; // GUIComponentSlider::mFocused (bool) + static constexpr std::size_t textbox_use_selected_color_off = 0x5'52; // GUIComponentTextBox::mUseSelectedTextColor + static constexpr std::size_t vtable_on_mouse_off_offset = 0x00'60; // GUIComponent::OnMouseOff slot + static constexpr std::size_t vtable_on_focus_off_offset = 0x1'18; // GUIComponent::OnFocusOff slot + // Scalar deleting destructor slot in the GUIComponent vtable. Called with flags=0 it destructs and frees any owned // sub-components without the final operator delete, so we then. _aligned_free. static constexpr std::size_t vtable_deleting_dtor_offset = 0x1'88; @@ -2973,6 +2983,53 @@ namespace big::mod_settings g_last_description_component = nullptr; } + // Calls a no-argument GUIComponent virtual (by byte offset into the vtable) on a component. Used to invoke the + // engine's own OnMouseOff / OnFocusOff so their full revert (text-colour flag plus the fill-texture swap) runs. + static void call_component_vfn(GUIComponent* comp, std::size_t vtable_byte_offset) + { + char* vtable = *reinterpret_cast(comp); + void* fn = *reinterpret_cast(vtable + vtable_byte_offset); + reinterpret_cast(fn)(comp); + } + + // Reverts a stale slider highlight left on the wrong row. Unlike a button, a slider has no Draw-time highlight + // gate: its moused-over look (green label plus bright fill) is child state set by GUIComponentSlider::OnMouseOver + // and its focused look (green value) by OnFocusOn, and each is undone only by the matching OnMouseOff / OnFocusOff, + // never re-derived in Draw. A rebuild or our hover re-assert (which writes mMouseOverComponent directly, bypassing + // the native OnMouseOver / OnMouseOff pairing) can therefore strand that state on a row the cursor has since left, + // leaving it stuck green until hovered again. Each frame we revert the moused-over look on any slider row that + // is not the live mouse-over component, and the focused look on any slider row that is not the focused option, so + // exactly the active row stays highlighted. The flag and the pointer are only ever inconsistent when stranded (the + // engine sets both together in one pass), so a genuinely hovered slider is kept. Buttons and text rows self-correct + // via their own Draw gate, so only slider rows need this. + static void clear_stale_slider_highlight(MiscSettingsScreen* screen) + { + auto* menu = reinterpret_cast(screen); + for (const auto& row : g_rows) + { + if (!row.is_slider || !row.component) + { + continue; + } + char* s = reinterpret_cast(row.component); + + // Moused-over look lives on the left label textbox. Revert it unless this row is the live mouse-over. + if (row.component != menu->m_mouse_over_component) + { + if (auto* label = *reinterpret_cast(s + slider_label_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) + { + call_component_vfn(row.component, vtable_on_mouse_off_offset); + } + } + + // Focused look lives on mFocused / the value textbox. Revert it unless this row is the focused option. + if (row.component != screen->m_component_focused && *reinterpret_cast(s + slider_focused_offset)) + { + call_component_vfn(row.component, vtable_on_focus_off_offset); + } + } + } + static void build_panel(MiscSettingsScreen* screen, bool instant = false) { // A rebuild frees and recreates the row components, so the cached highlighted-row pointer is stale force the @@ -4073,6 +4130,12 @@ namespace big::mod_settings // native prompts alone). sync_prompts(screen, on_mods_tab); + // Revert any slider highlight left stranded on the wrong row by a rebuild or the hover re-assert. + if (on_mods_tab) + { + clear_stale_slider_highlight(screen); + } + return result; } From e1dadbdbc0e06c1195a3ccf18fb849529dd6e058 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:39:23 +0100 Subject: [PATCH 045/100] Skip disabled rows in Mods-tab keyboard/controller navigation --- src/hades2/mod_settings/mod_settings.cpp | 117 ++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 22fd303..cfa6174 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -173,6 +173,12 @@ namespace big::mod_settings static constexpr std::size_t component_free_form_offset_y_offset = 0x1'58; // mFreeFormSelectOffsetY (float) static constexpr std::size_t component_auto_activate_offset = 0x00'BC; // mAutoActivateWithGamepad (bool) + // mData.mDef.mFreeFormSelectable: the spatial keyboard/controller nav (SearchInDirection) skips any candidate whose + // byte here is false, before it even calls IsSelectable. Mouse hover (MenuScreen::UpdateMouseOver) does not read + // it, so clearing it makes DOWN/UP nav jump over a row while the mouse can still hover it (to read its + // description). We clear it on disabled/greyed rows so the cursor only lands on interactable ones. + static constexpr std::size_t component_free_form_selectable_offset = 0x00'B1; // mData.mDef.mFreeFormSelectable (bool) + // The Button_Secondary sprite's native atlas width in px. The box draws at native * mScale * mScaleX. static constexpr float button_graphic_native_width = 350.0f; @@ -242,6 +248,7 @@ namespace big::mod_settings using slider_defaults_fn = void (*)(void* slider); using slider_set_fraction_fn = void (*)(void* slider, float fraction, bool notify); using teleport_cursor_fn = void (*)(void* menu_screen, GUIComponent* component); + using set_mouse_over_fn = void (*)(void* menu_screen, GUIComponent* component); using component_focused_fn = void (*)(void* misc_settings_screen, GUIComponent* component); using input_get_state_fn = std::uint32_t (*)(void* input_handler, const void* remappable_control); using mouse_button_down_fn = bool (*)(void* input_handler); @@ -287,6 +294,7 @@ namespace big::mod_settings static slider_set_fraction_fn g_slider_set_fraction = nullptr; static std::uintptr_t g_slider_vtable = 0; // runtime static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) + static set_mouse_over_fn g_set_mouse_over = nullptr; // MenuScreen::SetMouseOver (highlight + select a row) static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) static const char* g_config_language = nullptr; // sgg::ConfigOptions::Language @@ -2903,6 +2911,68 @@ namespace big::mod_settings } } + // After a native page scroll (the on-screen arrow's auto-activate fires MiscSettingsScreen::ScrollDown / ScrollUp), + // the engine selects the new page's edge row directly - mOptions[pageStart] going down, the last on-page row going + // up - via SetMouseOver plus a free-form cursor teleport, without consulting mFreeFormSelectable. So when that edge + // row is disabled the cursor lands on it (a hidden highlight on a greyed row) instead of the first interactable + // row. This runs right after the native handler when the page changed under keyboard/controller: it finds the + // first (going down) or last (going up) eligible row on the new page and moves the highlight and cursor there. If + // every row on the page is disabled it leaves the native edge selection as a fallback. Mouse mode is not touched + // (the pointer drives hover itself). + static void redirect_page_landing(MiscSettingsScreen* screen, bool going_down) + { + if (!g_set_mouse_over || !g_teleport_cursor || (g_use_mouse && *g_use_mouse) || g_rows.empty()) + { + return; + } + const std::size_t page_start = screen->m_page_start_index; + if (page_start >= g_rows.size()) + { + return; + } + const std::size_t page_end = std::min(page_start + rows_per_page, g_rows.size()); // exclusive + + const auto eligible = [](const PanelRow& r) + { + return r.component && !r.disabled && r.component->m_is_useable && !r.component->m_hidden; + }; + + GUIComponent* target = nullptr; + if (going_down) + { + for (std::size_t i = page_start; i < page_end; ++i) + { + if (eligible(g_rows[i])) + { + target = g_rows[i].component; + break; + } + } + } + else + { + for (std::size_t i = page_end; i-- > page_start;) + { + if (eligible(g_rows[i])) + { + target = g_rows[i].component; + break; + } + } + } + + // No eligible row on this page (all disabled): keep the native edge selection. + auto* menu = reinterpret_cast(screen); + if (!target || menu->m_mouse_over_component == target) + { + return; + } + + g_set_mouse_over(screen, target); // remove the highlight from the edge row and place it on the eligible one + g_teleport_cursor(screen, target); // the free-form cursor follows so the next press moves from here + screen->m_category_focused = false; + } + // The row a pending back-navigation should re-focus: the mod_entry row of the mod that was open (focus_stem set), // or the group row that drills into the section that was open (focus_section set). Exactly one of the two fields is // set per restore. Returns nullptr if that row is not in the freshly built view (e.g. it was removed since). @@ -3030,6 +3100,32 @@ namespace big::mod_settings } } + // Makes the keyboard/controller spatial nav skip every disabled/greyed row so DOWN/UP jumps straight to the next + // interactable one (with the native wrap and cross-page paging), while leaving mouse hover untouched so a mouse + // user can still rest on a greyed row to read its description. It clears mData.mDef.mFreeFormSelectable on each + // disabled row and the paired value-display column - the only gate SearchInDirection checks before IsSelectable, + // and one MenuScreen::UpdateMouseOver never reads. Interactable rows keep the template default (selectable). Called + // after every build: the row objects are recreated each time, so a fresh build restores the default before this + // reapplies it. + static void apply_row_freeform_selectability() + { + for (const auto& row : g_rows) + { + if (!row.disabled) + { + continue; + } + if (row.component) + { + *reinterpret_cast(reinterpret_cast(row.component) + component_free_form_selectable_offset) = false; + } + if (row.value_component) + { + *reinterpret_cast(reinterpret_cast(row.value_component) + component_free_form_selectable_offset) = false; + } + } + } + static void build_panel(MiscSettingsScreen* screen, bool instant = false) { // A rebuild frees and recreates the row components, so the cached highlighted-row pointer is stale force the @@ -3132,6 +3228,10 @@ namespace big::mod_settings // are already shifted here. sync_value_columns(); + // Take the disabled/greyed rows out of the keyboard/controller nav so the cursor only lands on interactable + // ones (mouse hover is unaffected). + apply_row_freeform_selectability(); + // On a real view change (tab entry, drilling in, going back), drop the cursor on the first row so it highlights // immediately like a native category. Skipped on in-place refreshes so committing an edit or toggling "enabled" // does not yank focus back to the top. When backing out, focus the row the user drilled through (the mod in the @@ -4198,7 +4298,21 @@ namespace big::mod_settings } } - return big::g_hooking->get_original()(self, input, x); + // The native handler runs the keyboard/controller nav, including the on-screen scroll arrow's auto-activate at + // a page edge, which pages via ScrollDown / ScrollUp and selects the new page's edge row. Capture the page + // index across the call so we can correct that landing when it falls on a disabled row (see + // redirect_page_landing). Only meaningful under keyboard/controller on the Mods tab. + const bool track_paging = on_mods_tab && !(g_use_mouse && *g_use_mouse); + const std::uint32_t page_before = screen->m_page_start_index; + + auto result = big::g_hooking->get_original()(self, input, x); + + if (track_paging && screen->m_page_start_index != page_before) + { + redirect_page_landing(screen, screen->m_page_start_index > page_before); + } + + return result; } // Close funnel for the options screen: every way the user dismisses it (Escape key, controller B, or clicking the @@ -4334,6 +4448,7 @@ namespace big::mod_settings // the Back/Cancel control edge for our drilldown back-nav. Both by name. The Controls::Cancel address is // RVA-relative (resolved below). Optional - their absence only degrades controller support, not the tab. g_component_focused = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ComponentFocused"].as_func(); + g_set_mouse_over = big::hades2_symbol_to_address["sgg::MenuScreen::SetMouseOver"].as_func(); g_input_get_state = big::hades2_symbol_to_address["sgg::InputHandler::GetState"].as_func(); g_mouse_button_down = big::hades2_symbol_to_address["sgg::InputHandler::IsLeftOrRightMouseButtonDown"].as_func(); From f4d11eb1eafe407d5f093bcbcccac9aaa3d06202 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:26:32 +0100 Subject: [PATCH 046/100] Only adjust the entered slider with left/right in Mods settings --- src/hades2/mod_settings/mod_settings.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index cfa6174..1428d1c 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -3923,7 +3923,8 @@ namespace big::mod_settings { if (self && !(g_use_mouse && *g_use_mouse)) { - PanelRow* row = find_row(reinterpret_cast(self)); + const bool focused = *reinterpret_cast(reinterpret_cast(self) + slider_focused_offset); + PanelRow* row = focused ? find_row(reinterpret_cast(self)) : nullptr; if (row && row->is_slider && !row->disabled && row->entry) { if (g_input_was_right_pressed(input)) @@ -3934,7 +3935,7 @@ namespace big::mod_settings { step_slider_row(self, row, -1); } - return true; // own the slider's keyboard/controller input so the native continuous slide never runs + return true; // own the focused slider's keyboard/controller input so the native continuous slide never runs } } return big::g_hooking->get_original()(self, input, dt); From 647b2b1e81a7da805c9db4ad839d55b2fbcd8fcb Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:59:26 +0100 Subject: [PATCH 047/100] Play the vanilla toggle click sound for boolean setting rows --- src/hades2/mod_settings/mod_settings.cpp | 47 +++++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 1428d1c..040e07f 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -54,15 +54,22 @@ namespace big::mod_settings static constexpr std::size_t def_add_text_area = 0x06; // mAddTextArea (bool) static constexpr std::size_t def_deselect_on_mouse_off = 0x13; // mDeselectOnMouseOff (bool) - static constexpr std::size_t def_y = 0x20; // mY (float) row Y, read by UpdateScrollState - static constexpr std::size_t def_offset_y = 0x2C; // mOffsetY (float) template vertical offset - static constexpr std::size_t def_scale = 0x34; // mScale (float) uniform component scale - static constexpr std::size_t def_text_offset_x = 0x50; // mTextOffsetX (float) - static constexpr std::size_t def_width = 0x74; // mWidth (float -> mCustomWidth) - static constexpr std::size_t def_height = 0x78; // mHeight (float -> mCustomHeight) - static constexpr std::size_t def_graphic = 0x80; // mGraphic (HashGuid) - static constexpr std::size_t def_selected_graphic = 0x84; // mSelectedGraphic (HashGuid) - static constexpr std::size_t def_alternate_graphic = 0x88; // mAlternateGraphic (HashGuid) + static constexpr std::size_t def_y = 0x20; // mY (float) row Y, read by UpdateScrollState + static constexpr std::size_t def_offset_y = 0x2C; // mOffsetY (float) template vertical offset + static constexpr std::size_t def_scale = 0x34; // mScale (float) uniform component scale + static constexpr std::size_t def_text_offset_x = 0x50; // mTextOffsetX (float) + static constexpr std::size_t def_width = 0x74; // mWidth (float -> mCustomWidth) + static constexpr std::size_t def_height = 0x78; // mHeight (float -> mCustomHeight) + static constexpr std::size_t def_graphic = 0x80; // mGraphic (HashGuid) + static constexpr std::size_t def_selected_graphic = 0x84; // mSelectedGraphic (HashGuid) + static constexpr std::size_t def_alternate_graphic = 0x88; // mAlternateGraphic (HashGuid) + // SoundCue def fields (each sgg::SoundCue is 0x10 bytes: pOwner @0, mName HashGuid id @8). The base OnClicked plays + // mPressSound; the native toggle handler ToggleOptionValueChanged (which our C++ toggle path replaces) is what + // plays mToggleOnSound / mToggleOffSound, so we copy the matching one into mPressSound to reproduce the sound. + static constexpr std::size_t def_press_sound = 0x1'B0; // mPressSound (sgg::SoundCue) + static constexpr std::size_t def_toggle_on_sound = 0x1'E0; // mToggleOnSound (sgg::SoundCue) + static constexpr std::size_t def_toggle_off_sound = 0x1'F0; // mToggleOffSound (sgg::SoundCue) + static constexpr std::size_t sound_cue_size = 0x10; // sizeof sgg::SoundCue static constexpr std::size_t def_add_color = 0x0D; // mAddColor (bool) static constexpr std::size_t def_red = 0xEC; // mRed button tint (float) static constexpr std::size_t def_green = 0xF0; // mGreen button tint (float) @@ -843,6 +850,19 @@ namespace big::mod_settings g_set_normal_texture(row, is_on ? on_hash : off_hash, false); } + // Reproduces the vanilla toggle click sound. A native ConfigOptions toggle plays mToggleOnSound / mToggleOffSound + // from its ValueChanged handler (MiscSettingsScreen::ToggleOptionValueChanged), which our C++ toggle path replaces, + // so a toggle would otherwise be silent (the base GUIComponent::OnClicked only plays mPressSound, which the + // OptionToggleButton template leaves unset). We copy the cue for the value the click will produce into mPressSound + // just before the base OnClicked runs, so its own audio path plays it with the correct swap handling. The rows are + // built on the OptionToggleButton template, so they already carry both toggle cues in their def. + static void stage_toggle_press_sound(GUIComponent* row, bool new_value) + { + char* def = reinterpret_cast(row) + component_def_offset; + const std::size_t src = new_value ? def_toggle_on_sound : def_toggle_off_sound; + std::memcpy(def + def_press_sound, def + src, sound_cue_size); + } + // Dims a row's def text colours (both normal and selected) so a disabled row reads as greyed out and does not // recolour on hover. Must be applied before SetupComponent so the change reaches the text box. static void set_def_text_grey(GUIComponent* row) @@ -3974,6 +3994,15 @@ namespace big::mod_settings } } + // A boolean toggle flips in our own code below rather than through the native toggle handler that plays the + // click sound, so stage the matching toggle cue as the press sound before the base OnClicked runs (it plays + // mPressSound). Predict the value the click produces (the flip of the current one) to pick the on / off cue. + if (matched && !matched_row.disabled && matched_row.kind == RowKind::setting && matched_row.entry + && matched_row.entry->type() == typeid(bool)) + { + stage_toggle_press_sound(self, !matched_row.entry->get_value_base()); + } + const bool result = big::g_hooking->get_original()(self, location); if (matched && !matched_row.disabled) From 24664a3b660dfea0bdf2eb41fa0c423a71f66c2b Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:55:18 +0100 Subject: [PATCH 048/100] Disable and grey slider and enum rows when a mod is disabled --- src/hades2/mod_settings/mod_settings.cpp | 182 ++++++++++++++++++----- 1 file changed, 142 insertions(+), 40 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 040e07f..055b693 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -153,6 +153,7 @@ namespace big::mod_settings static constexpr std::size_t numbox_is_integer_offset = 0x5'50; // mIsInteger (bool: discrete + integer display) static constexpr std::size_t numbox_disable_input_offset = 0x5'63; // mDisableInput (bool: HandleInput early-out) static constexpr std::size_t numbox_value_text_offset = 0x5'B0; // mValueTextBox (GUIComponentTextBox*) + static constexpr std::size_t numbox_anim_offset = 0x5'90; // mAnim (GUIComponentAnimation*, box graphic) static constexpr std::size_t numbox_left_arrow_offset = 0x5'98; // mLeftArrow (GUIComponentAnimation*) static constexpr std::size_t numbox_right_arrow_offset = 0x5'A0; // mRightArrow (GUIComponentAnimation*) static constexpr std::size_t numbox_label_text_offset = 0x5'A8; // mTextBox (GUIComponentTextBox*, the label) @@ -223,8 +224,33 @@ namespace big::mod_settings static constexpr std::size_t slider_focused_offset = 0x5'48; // GUIComponentSlider::mFocused (bool) static constexpr std::size_t textbox_use_selected_color_off = 0x5'52; // GUIComponentTextBox::mUseSelectedTextColor static constexpr std::size_t vtable_on_mouse_off_offset = 0x00'60; // GUIComponent::OnMouseOff slot + static constexpr std::size_t vtable_on_unselected_offset = 0x00'88; // GUIComponent::OnUnselected slot static constexpr std::size_t vtable_on_focus_off_offset = 0x1'18; // GUIComponent::OnFocusOff slot + // Disabled-greying of a slider / num-box, which are multi-sub-component widgets: the button-style def text greying + // does not reach their separate label / value text boxes or their bar / arrow graphics, so each is greyed directly. + // A GUIComponentTextBox renders its mDisabledText colour when mUseDisabledTextColor is set (Slider / NumBox Draw + // set it on the LABEL each frame from mIsUseable, but only if the box's def carries a non-negative disabled colour, + // so we write that colour explicitly and also flag the value box, which Draw never touches). A GUIComponentImage + // (slider bar) tints from mColor every frame, so writing mColor (and mColorTarget so a lerp does not undo it) dims + // it. Offsets on the text box / image component are absolute. + static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor + static constexpr std::size_t textbox_disabled_text_red = 0x1'E8; // mData.mDef.mDisabledTextRed (float) + static constexpr std::size_t textbox_disabled_text_green = 0x1'EC; // mDisabledTextGreen (float) + static constexpr std::size_t textbox_disabled_text_blue = 0x1'F0; // mDisabledTextBlue (float) + static constexpr std::size_t textbox_disabled_text_alpha = 0x1'F4; // mDisabledTextAlpha (float) + static constexpr std::size_t image_color_offset = 0x5'44; // GUIComponentImage::mColor (packed RGBA) + static constexpr std::size_t image_color_target_offset = 0x00'78; // mColorTarget (packed RGBA) + static constexpr float disabled_text_grey = 0.22f; // matches set_def_text_grey (toggle/text rows) + static constexpr std::uint32_t disabled_graphic_grey = 0xFF'66'66'66; // opaque 0.4 grey (packed A,B,G,R) + + // A GUIComponentAnimation (the num-box's box/frame graphic) tints from its own mColor. NumBox::OnSelected turns the + // box black by writing the selected colour here (opaque black for the OptionNumBox template); we write the same on + // a disabled num-box so its background matches the hovered look. NumBox Draw / Update never touch this field, so a + // one-time write on a non-selectable (disabled) box sticks. + static constexpr std::size_t animation_color_offset = 0x5'58; // GUIComponentAnimation::mColor (packed ARGB) + static constexpr std::uint32_t numbox_hover_bg_black = 0xFF'00'00'00; // the num-box's hovered/selected box colour + // Scalar deleting destructor slot in the GUIComponent vtable. Called with flags=0 it destructs and frees any owned // sub-components without the final operator delete, so we then. _aligned_free. static constexpr std::size_t vtable_deleting_dtor_offset = 0x1'88; @@ -877,6 +903,38 @@ namespace big::mod_settings *reinterpret_cast(def + def_sel_text_blue) = grey; } + // Greys a child GUIComponentTextBox (a slider / num-box label or value box) by giving it a disabled text colour and + // flagging it to use that colour. Draw greys only the LABEL (from the parent's mIsUseable) and only when the box's + // def already carries a disabled colour, so we set the colour here; for the value box, which Draw never touches, + // the flag persists too. Grey text colour matches set_def_text_grey so every disabled row reads the same. + static void grey_text_box(void* text_box) + { + if (!text_box) + { + return; + } + char* b = static_cast(text_box); + *reinterpret_cast(b + textbox_disabled_text_red) = disabled_text_grey; + *reinterpret_cast(b + textbox_disabled_text_green) = disabled_text_grey; + *reinterpret_cast(b + textbox_disabled_text_blue) = disabled_text_grey; + *reinterpret_cast(b + textbox_disabled_text_alpha) = 1.0f; + *reinterpret_cast(b + textbox_use_disabled_color_off) = true; + } + + // Dims a GUIComponentImage (a slider's bar backing / fill) to the disabled grey. Image::Draw tints from mColor each + // frame and neither Slider::Draw nor Slider::Update recolour the bar, so writing mColor (plus mColorTarget so the + // per-frame lerp does not pull it back) sticks. + static void grey_image(void* image) + { + if (!image) + { + return; + } + char* b = static_cast(image); + *reinterpret_cast(b + image_color_offset) = disabled_graphic_grey; + *reinterpret_cast(b + image_color_target_offset) = disabled_graphic_grey; + } + // Sets a row's normal text colour to the native settings-option grey (0.55) used by the game's own. // OptionToggleButton / OptionNumBox rows, so plain-text (key/value) rows built on the CategoryOptionsButton // template (whose own text is a darker 0.35) match the toggle rows instead of reading as brighter full white. The @@ -1290,7 +1348,20 @@ namespace big::mod_settings if (disabled) { + // mDisableInput is the num-box's own input gate (its HandleInput early-outs on it), blocking both the + // arrow-clicks and keyboard/controller stepping - mIsUseable does NOT gate num-box input. Also clear + // mIsUseable so it is non-selectable (nav/hover skip it and NumBox::Draw greys the label from mIsUseable) + // and grey the value box. The arrows are left visible (just inert, since mDisableInput blocks their click). + // The box graphic is set to the hovered/selected black so a disabled enum reads with the same black + // background it shows on hover (the box is non-selectable, so nothing reverts this write). *reinterpret_cast(nb_bytes + numbox_disable_input_offset) = true; + nb->m_is_useable = false; + grey_text_box(*reinterpret_cast(nb_bytes + numbox_label_text_offset)); + grey_text_box(*reinterpret_cast(nb_bytes + numbox_value_text_offset)); + if (auto* box = *reinterpret_cast(nb_bytes + numbox_anim_offset)) + { + *reinterpret_cast(box + animation_color_offset) = numbox_hover_bg_black; + } } finalize_row(screen, nb); @@ -1419,11 +1490,6 @@ namespace big::mod_settings set_sso_string(s + gui_component_name_offset, "OptionSlider"); set_sso_string(val + gui_component_name_offset, "OptionSliderValueText"); - if (disabled) - { - set_def_text_grey(reinterpret_cast(s)); // grey. - } - g_apply_data(reinterpret_cast(screen), reinterpret_cast(s)); // Override the template's row grid (Y=300, Spacing=45) so the bar lines up with the other rows. @@ -1448,7 +1514,16 @@ namespace big::mod_settings if (disabled) { - reinterpret_cast(s)->m_is_useable = false; // not focusable / not draggable + // Non-interactive (mIsUseable=0 makes it non-selectable, so nav/hover skip it and Slider::Draw greys the + // label from mIsUseable), plus explicit greying of the parts Draw leaves bright: the value box and both bar + // images. Mouse-drag is separately blocked in the HandleInput hook (the native drag path ignores + // mIsUseable). + auto* sc = reinterpret_cast(s); + sc->m_is_useable = false; + grey_text_box(*reinterpret_cast(s + slider_label_offset)); + grey_text_box(*reinterpret_cast(s + slider_value_text_offset)); + grey_image(*reinterpret_cast(s + slider_backing_offset)); + grey_image(*reinterpret_cast(s + slider_fill_offset)); } finalize_row(screen, reinterpret_cast(s)); @@ -3082,40 +3157,60 @@ namespace big::mod_settings reinterpret_cast(fn)(comp); } - // Reverts a stale slider highlight left on the wrong row. Unlike a button, a slider has no Draw-time highlight - // gate: its moused-over look (green label plus bright fill) is child state set by GUIComponentSlider::OnMouseOver - // and its focused look (green value) by OnFocusOn, and each is undone only by the matching OnMouseOff / OnFocusOff, + // Reverts a stale highlight left on the wrong slider or num-box row. Unlike a button, these have no Draw-time + // highlight gate: their lit look is child state set by an OnXxxOn handler and undone only by the matching OnXxxOff, // never re-derived in Draw. A rebuild or our hover re-assert (which writes mMouseOverComponent directly, bypassing - // the native OnMouseOver / OnMouseOff pairing) can therefore strand that state on a row the cursor has since left, - // leaving it stuck green until hovered again. Each frame we revert the moused-over look on any slider row that - // is not the live mouse-over component, and the focused look on any slider row that is not the focused option, so - // exactly the active row stays highlighted. The flag and the pointer are only ever inconsistent when stranded (the - // engine sets both together in one pass), so a genuinely hovered slider is kept. Buttons and text rows self-correct - // via their own Draw gate, so only slider rows need this. - static void clear_stale_slider_highlight(MiscSettingsScreen* screen) + // the native OnMouseOver / OnMouseOff pairing) can strand that state on a row the cursor has since left, leaving it + // stuck lit until hovered again. Each frame we revert it on any such row that is not the live mouse-over / focused + // component, so exactly the active row stays highlighted. + // + // Slider and num-box differ in WHICH handler sets the look: a slider's moused-over look (green label + bright fill) + // is set by OnMouseOver and reverted by OnMouseOff (vtbl+0x60); a num-box's lit look (black box + green label) is + // set by OnSelected and reverted by OnUnselected (vtbl+0x88) - its OnMouseOff is an inherited no-op. The num-box + // OnSelected look fires under keyboard/controller nav too (not just mouse), so its revert is gated to mouse mode to + // avoid clearing a genuine gamepad selection; the slider moused-over flag is only ever set in mouse mode, so its + // revert needs no such gate. Both also carry a focus look (green value + mFocused) reverted by OnFocusOff + // (vtbl+0x118). Buttons and text rows self-correct via their own Draw gate, so only these two widgets need this. + static void clear_stale_widget_highlight(MiscSettingsScreen* screen) { - auto* menu = reinterpret_cast(screen); + auto* menu = reinterpret_cast(screen); + const bool mouse_mode = g_use_mouse && *g_use_mouse; for (const auto& row : g_rows) { - if (!row.is_slider || !row.component) + if (!row.component) { continue; } char* s = reinterpret_cast(row.component); - // Moused-over look lives on the left label textbox. Revert it unless this row is the live mouse-over. - if (row.component != menu->m_mouse_over_component) + if (row.is_slider) { - if (auto* label = *reinterpret_cast(s + slider_label_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) + // Moused-over look lives on the left label textbox. Revert it unless this row is the live mouse-over. + if (row.component != menu->m_mouse_over_component) { - call_component_vfn(row.component, vtable_on_mouse_off_offset); + if (auto* label = *reinterpret_cast(s + slider_label_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) + { + call_component_vfn(row.component, vtable_on_mouse_off_offset); + } } - } - // Focused look lives on mFocused / the value textbox. Revert it unless this row is the focused option. - if (row.component != screen->m_component_focused && *reinterpret_cast(s + slider_focused_offset)) + // Focused look lives on mFocused / the value textbox. Revert it unless this row is the focused option. + if (row.component != screen->m_component_focused && *reinterpret_cast(s + slider_focused_offset)) + { + call_component_vfn(row.component, vtable_on_focus_off_offset); + } + } + else if ((row.is_enum || row.is_stepper) && mouse_mode) { - call_component_vfn(row.component, vtable_on_focus_off_offset); + // Num-box selected look (black box + green label) set by OnSelected, on the label textbox mUseSelected + // flag. Revert via OnUnselected (not OnMouseOff, a no-op here) unless it is the live mouse-over. + if (row.component != menu->m_mouse_over_component) + { + if (auto* label = *reinterpret_cast(s + numbox_label_text_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) + { + call_component_vfn(row.component, vtable_on_unselected_offset); + } + } } } } @@ -3932,20 +4027,27 @@ namespace big::mod_settings g_slider_set_fraction(slider, static_cast((v - min_v) / range), true); } - // Discrete keyboard/controller stepping for our slider rows. The native GUIComponentSlider::HandleInput slides - // mFraction continuously (axisSum * speed * dt behind a 0.5 dead-zone, summing dpad, arrow keys, WASD and the left - // stick), so a small tap can land back on the same snapped value. For our rows under keyboard/controller (UseMouse - // off) we bypass that path and move exactly one step on each left/right press edge, so every input changes the - // value by at least one step and a held direction cannot creep between steps. Mouse drag (UseMouse on) and every - // native slider keep the original continuous behaviour. If the edge probes are missing the whole path is skipped at - // install time, so this only runs when both are available. + // Discrete keyboard/controller stepping for our slider rows, and a disabled-row guard. The native + // GUIComponentSlider::HandleInput slides mFraction continuously (axisSum * speed * dt behind a 0.5 dead-zone, + // summing dpad, arrow keys, WASD and the left stick), so a small tap can land back on the same snapped value. For + // our rows under keyboard/controller (UseMouse off) we bypass that path and move exactly one step on each + // left/right press edge, so every input changes the value by at least one step and a held direction cannot creep + // between steps, gated on the slider's own mFocused@0x548 (the native slide gate) so only the entered slider - not + // every visible one - reacts. Mouse drag (UseMouse on) and every native slider keep the original continuous + // behaviour. If the edge probes are missing the whole path is skipped at install time, so this only runs when both + // are available. A DISABLED slider row must not adjust in any mode: the native mouse-drag path (UseMouse && + // button-down && backing under cursor) never checks mIsUseable, so we swallow its input here or a greyed slider + // would still drag under the mouse. static bool hook_GUIComponentSlider_HandleInput(void* self, void* input, float dt) { - if (self && !(g_use_mouse && *g_use_mouse)) + PanelRow* row = self ? find_row(reinterpret_cast(self)) : nullptr; + if (row && row->is_slider) { - const bool focused = *reinterpret_cast(reinterpret_cast(self) + slider_focused_offset); - PanelRow* row = focused ? find_row(reinterpret_cast(self)) : nullptr; - if (row && row->is_slider && !row->disabled && row->entry) + if (row->disabled) + { + return false; // greyed slider: swallow so the native mouse-drag / slide never adjusts it + } + if (row->entry && !(g_use_mouse && *g_use_mouse) && *reinterpret_cast(reinterpret_cast(self) + slider_focused_offset)) { if (g_input_was_right_pressed(input)) { @@ -3955,7 +4057,7 @@ namespace big::mod_settings { step_slider_row(self, row, -1); } - return true; // own the focused slider's keyboard/controller input so the native continuous slide never runs + return true; // own the focused slider's input so the native continuous slide never runs } } return big::g_hooking->get_original()(self, input, dt); @@ -4260,10 +4362,10 @@ namespace big::mod_settings // native prompts alone). sync_prompts(screen, on_mods_tab); - // Revert any slider highlight left stranded on the wrong row by a rebuild or the hover re-assert. + // Revert any slider / num-box highlight left stranded on the wrong row by a rebuild or the hover re-assert. if (on_mods_tab) { - clear_stale_slider_highlight(screen); + clear_stale_widget_highlight(screen); } return result; From 928e97b58eb78fc6fe9db6c3b838a4a3e72c76a0 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:45:58 +0100 Subject: [PATCH 049/100] Update naming scheme --- docs/mod_settings/README.md | 40 ++++++++++---------- docs/mod_settings/config_schema.lua | 18 ++++----- src/hades2/mod_settings/config_api.cpp | 48 ++++++++++++------------ src/hades2/mod_settings/mod_settings.hpp | 4 +- 4 files changed, 55 insertions(+), 55 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index b076977..2928c95 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -23,7 +23,7 @@ Hover any field in the editor for its documentation. The available fields on a s | Field | Type | Purpose | | --- | --- | --- | -| `display_name` | string \| localization table \| callback | Row label (defaults to a prettified key). | +| `displayName` | string \| localization table \| callback | Row label (defaults to a prettified key). | | `description` | string \| localization table \| callback | Help text in the description box. Keep each line ~35 chars to leave space for free-text input strings. | | `min`/`max` | number \| callback | Numeric bounds. If both are present the input will turn into a slider (such as for volume control). | | `step` | number \| callback | Slider/number step size (default 1). Will clamp user input automatically. | @@ -33,18 +33,18 @@ Hover any field in the editor for its documentation. The available fields on a s | `hidden` | boolean | Hide the setting from the menu entirely. Static only - use `disabled` for a condition that changes while the menu is open. | | `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. See below. | | `freetext` | boolean | Force a bounded number to be a free-text entry instead of a slider. | -| `restart_required` | boolean | Force the user to restart the game when this setting is changed. | -| `editable_context` | `"any"` \| `"main_menu"` \| `"in_save"` | If this setting can be changed only in the main menu, only in a save, or in both. When the current context does not match, the row is shown read-only with a note. The "enabled" setting and any `restart_required` settings are always treated as `"main_menu"`. Defaults to `"any"`. | -| `show_as_percentage` | boolean | Append "%" to the value. | -| `is_percentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | -| `on_change` | `fun(key, new_value)` | Called after the setting is changed in the in-game menu. Use it to apply the change to the loaded run. See below. | +| `restartRequired` | boolean | Force the user to restart the game when this setting is changed. | +| `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` | If this setting can be changed only in the main menu, only in a save, or in both. When the current context does not match, the row is shown read-only with a note. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. Defaults to `"any"`. | +| `showAsPercentage` | boolean | Append "%" to the value. | +| `isPercentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | +| `onChange` | `fun(key, new_value)` | Called after the setting is changed in the in-game menu. Use it to apply the change to the loaded run. See below. | ## Dynamic fields (functions) Most fields can also be dynamically resolved through a function call, which is evaluated when the menu is opened and refreshed (after any other setting is changed). This lets a setting react to the live game state or to other settings. The following may be a **function** returning the value instead of a -literal: `display_name`, `description`, `min`, `max`, `step`, `values`, `labels`, `order`, and +literal: `displayName`, `description`, `min`, `max`, `step`, `values`, `labels`, `order`, and `disabled`. The function runs in your mod's environment, so it can read your `config`, and call functions in your `mod` or the `game` namespace. @@ -52,12 +52,12 @@ Examples: ```lua biome_count = { - display_name = "Number of Regions", + displayName = "Number of Regions", min = 2, max = function() return mod.MaxAllowedBiomeCount end, -- 8 or 12, resolved live }, meta_reward_fix_chance_cap = { - display_name = "Meta Reward Chance Cap", + displayName = "Meta Reward Chance Cap", min = 30, max = 90, disabled = function() return not mod.config.meta_reward_fix end, -- greyed unless the fix toggle is on }, @@ -69,30 +69,30 @@ static only - it is evaluated only when the menu builds, and cannot be changed d ## Action buttons A `configDesc` entry with an `action` function (and a key that has NO config value) renders as a button -that runs the callback when pressed, instead of editing a setting. It supports `display_name`, `description`, -`order`, `editable_context`, and `disabled` (grey the button live, e.g. until a value has changed). +that runs the callback when pressed, instead of editing a setting. It supports `displayName`, `description`, +`order`, `editableContext`, and `disabled` (grey the button live, e.g. until a value has changed). ```lua apply_scaling = { action = function() mod.ApplyLateBiomeScaling() end, - display_name = "Apply Late Biome Scaling", + displayName = "Apply Late Biome Scaling", description = "Apply the scaling values above to the current run.", - editable_context = "in_save", -- greyed unless a save is loaded + editableContext = "inSave", -- greyed unless a save is loaded }, ``` -## Reacting to changes (`on_change`) +## Reacting to changes (`onChange`) -Give a setting an `on_change` function to e.g. apply its new value to the live game when the player +Give a setting an `onChange` function to e.g. apply its new value to the live game when the player changes it in the in-game options menu. It receives the setting's key and the new value: ```lua local configDesc = { hermes_shrine_chance = { - display_name = "Hermes Shrine Chance", + displayName = "Hermes Shrine Chance", min = 0, max = 100, - editable_context = "in_save", - on_change = function(key, new_value) + editableContext = "inSave", + onChange = function(key, new_value) mod.ApplyHermesShrineChance(new_value) -- re-apply the value to the live run end, }, @@ -106,12 +106,12 @@ in-game options menu, so: - It is **never called in the main menu** - there is no loaded run to apply to, and Lua game-data edits are discarded when a save loads. - It is **not called for other config writes** (e.g. from imgui or the config file). -- Re-writing the same value is a no-op and does not fire, so an `on_change` that writes another setting +- Re-writing the same value is a no-op and does not fire, so an `onChange` that writes another setting cannot loop. - Errors thrown in the callback are logged and do not propagate into the game. ## Localization tables -Any `display_name`, `description`, or `labels` entry may be a table keyed by the game's language folder +Any `displayName`, `description`, or `labels` entry may be a table keyed by the game's language folder codes (`en`, `de`, `el`, `es`, `fr`, `it`, `ja`, `ko`, `pl`, `pt-BR`, `ru`, `tr`, `uk`, `zh-CN`, `zh-TW`). The menu resolves it to the current game language, falling back to English. diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 7f24dac..343a5c3 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -20,7 +20,7 @@ --- to about 35 characters so it leaves enough space for free-text input strings. ---@field description? mod_settings.dynamic_string --- Row label. Defaults to a prettified version of the config key (e.g. `myCool_Setting` -> "My Cool Setting"). ----@field display_name? mod_settings.dynamic_string +---@field displayName? mod_settings.dynamic_string --- Lower bound for a numeric setting. Combined with `max`, the setting renders as a slider. ---@field min? mod_settings.dynamic_number --- Upper bound for a numeric setting. Combined with `min`, the setting renders as a slider. @@ -47,19 +47,19 @@ ---@field freetext? boolean --- Mark that changing this setting requires a game restart. The menu forces the player --- to restart when they leave the mod menu after changing it. ----@field restart_required? boolean +---@field restartRequired? boolean --- If this setting can be changed only in the main menu, only in a save, or in both. --- When the current context does not match, the row is shown read-only with a note. ---- The "enabled" setting and any `restart_required` settings are always treated as `"main_menu"`. ----@field editable_context? "any" | "main_menu" | "in_save" +--- The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. +---@field editableContext? "any" | "mainMenu" | "inSave" --- Append "%" to the displayed value. ----@field show_as_percentage? boolean +---@field showAsPercentage? boolean --- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). ----@field is_percentage? boolean +---@field isPercentage? boolean --- Called after this setting's value is changed through the in-game options menu, with the setting's key --- and the new value. Use it to apply the change to the loaded run. It is not called in the main menu. --- Re-writing the same value is a no-op and does not fire. Errors are logged, not propagated. ----@field on_change? fun(key: string, new_value: boolean|number|string) +---@field onChange? fun(key: string, new_value: boolean|number|string) --- An action button in the menu that runs a callback instead of editing a config value. Declare it as a --- `configDesc` entry (with a matching key that has NO config value) carrying an `action` function. @@ -67,13 +67,13 @@ --- The callback run when the button is activated. Runs in your mod's environment. ---@field action fun() --- Button label. Defaults to a prettified version of the key. ----@field display_name? mod_settings.dynamic_string +---@field displayName? mod_settings.dynamic_string --- Help text shown while the button is highlighted. ---@field description? mod_settings.dynamic_string --- Sort key among the section's rows, lower first. ---@field order? mod_settings.dynamic_number --- When the button is activated: only in the main menu, only in a save, or both. ----@field editable_context? "any" | "main_menu" | "in_save" +---@field editableContext? "any" | "mainMenu" | "inSave" --- Grey the button out (non-interactive) while this is true. Updates live while the menu is open (e.g. --- grey an "Apply" button until a value has actually changed). ---@field disabled? mod_settings.dynamic_boolean diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 7f6e8be..70c3be8 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -243,29 +243,29 @@ namespace big::mod_settings return {}; } - // True if a config.lua description table declares `restart_required = true`. + // True if a config.lua description table declares `restartRequired = true`. static bool description_requires_restart(const sol::object& desc) { if (!desc.is()) { return false; } - sol::object flag = desc.as()["restart_required"]; + sol::object flag = desc.as()["restartRequired"]; return flag.is() && flag.as(); } - // Parses an `editable_context` field ("any"/"main_menu"/"in_save") returns `fallback` for anything else. Shared by + // Parses an `editableContext` field ("any"/"mainMenu"/"inSave") returns `fallback` for anything else. Shared by // setting metadata and action buttons. static editable_context parse_editable_context(const sol::object& o, editable_context fallback) { if (o.get_type() == sol::type::string) { const std::string s = o.as(); - if (s == "main_menu") + if (s == "mainMenu") { return editable_context::main_menu; } - if (s == "in_save") + if (s == "inSave") { return editable_context::in_save; } @@ -315,9 +315,9 @@ namespace big::mod_settings setting_metadata m; m.description = describe(desc); - // Display-name override (`display_name`) empty -> the menu prettifies the key. May be a plain string or a + // Display-name override (`displayName`) empty -> the menu prettifies the key. May be a plain string or a // localization table. - sol::object display_name = desc["display_name"]; + sol::object display_name = desc["displayName"]; m.name = parse_localized(display_name); sol::object min_field = desc["min"]; @@ -382,12 +382,12 @@ namespace big::mod_settings m.freetext = freetext_field.as(); } - sol::object show_pct_field = desc["show_as_percentage"]; + sol::object show_pct_field = desc["showAsPercentage"]; if (show_pct_field.is()) { m.show_as_percentage = show_pct_field.as(); } - sol::object is_pct_field = desc["is_percentage"]; + sol::object is_pct_field = desc["isPercentage"]; if (is_pct_field.is()) { m.is_percentage = is_pct_field.as(); @@ -395,18 +395,18 @@ namespace big::mod_settings m.restart_required = description_requires_restart(desc); - // When the setting may be changed relative to a loaded save (`editable_context`). The menu forces the master - // "enabled" toggle and restart_required settings to main_menu regardless, so authors need only annotate the + // When the setting may be changed relative to a loaded save (`editableContext`). The menu forces the master + // "enabled" toggle and restartRequired settings to main_menu regardless, so authors need only annotate the // in-between cases. - m.context = parse_editable_context(desc["editable_context"], editable_context::any); + m.context = parse_editable_context(desc["editableContext"], editable_context::any); // A field written as a Lua function is a dynamic field: It is skipped by the type-guarded reads above (a // function is not a number/table/bool/string) and instead re-evaluated at render time by // resolve_setting_metadata. Record that any such field is present so the menu knows to resolve. `hidden` is // intentionally NOT dynamic: showing/hiding a row shifts the layout and the row set is only re-evaluated on a - // full rebuild, so a live-changing condition must use `disabled` instead. `editable_context` is a fixed design + // full rebuild, so a live-changing condition must use `disabled` instead. `editableContext` is a fixed design // property of a setting, so it is static too. - for (const char* field : {"display_name", "description", "min", "max", "step", "values", "labels", "order", "disabled"}) + for (const char* field : {"displayName", "description", "min", "max", "step", "values", "labels", "order", "disabled"}) { if (desc[field].get_type() == sol::type::function) { @@ -501,7 +501,7 @@ namespace big::mod_settings // Builds a shallow copy of a setting's description table with every dynamic (function) field replaced by its // evaluated value, so the existing extract_metadata can read it as if the author had written static values. - // `on_change` and `action` callables are intentionally left as-is (they are invoked on their own events, not read + // `onChange` and `action` callables are intentionally left as-is (they are invoked on their own events, not read // as metadata). static sol::table resolve_description(sol::state_view state, const sol::table& desc, const std::string& guid) { @@ -514,7 +514,7 @@ namespace big::mod_settings continue; } const std::string field = k.as(); - if (field == "on_change" || field == "action") + if (field == "onChange" || field == "action") { out[k] = v; continue; @@ -527,7 +527,7 @@ namespace big::mod_settings // Reads the static (non-function) action metadata common to collection and dynamic re-resolution. static void read_action_fields(const sol::table& entry, action_info& a) { - a.name = parse_localized(entry["display_name"]); + a.name = parse_localized(entry["displayName"]); a.description = describe(entry); if (sol::object o = entry["order"]; o.get_type() == sol::type::number) { @@ -538,7 +538,7 @@ namespace big::mod_settings { a.disabled = d.as(); } - a.context = parse_editable_context(entry["editable_context"], editable_context::any); + a.context = parse_editable_context(entry["editableContext"], editable_context::any); } // Walks a mod's configDesc (guided by the config defaults structure, like bind_defaults) collecting action buttons: @@ -565,7 +565,7 @@ namespace big::mod_settings a.section = section; a.key = k.as(); read_action_fields(entry, a); - for (const char* field : {"display_name", "description", "order", "disabled"}) + for (const char* field : {"displayName", "description", "order", "disabled"}) { if (entry[field].get_type() == sol::type::function) { @@ -644,7 +644,7 @@ namespace big::mod_settings } } - // Attaches a Lua on_change callback (from a setting's config.lua description) to its config entry. toml_v2 already + // Attaches a Lua onChange callback (from a setting's config.lua description) to its config entry. toml_v2 already // fires config_entry::m_setting_changed after a value changes and the file is saved. This routes that to Lua, // passing the new value and the setting key. It fires only for an edit made through the in-game options menu // (on_change_callbacks_enabled gates on the options screen being open in-game), so it is never called in the main @@ -671,7 +671,7 @@ namespace big::mod_settings if (!result.valid()) { const sol::error err = result; - LOG(WARNING) << "[mod_settings] on_change callback failed for " << changed->m_definition.m_section << "." + LOG(WARNING) << "[mod_settings] onChange callback failed for " << changed->m_definition.m_section << "." << changed->m_definition.m_key << ": " << err.what(); } }; @@ -796,17 +796,17 @@ namespace big::mod_settings } // A rich description table carries metadata. For a leaf it is the setting's metadata. For a nested group (a - // table value). It is group-level metadata (e.g. order/display_name/hidden) declared alongside the child + // table value). It is group-level metadata (e.g. order/displayName/hidden) declared alongside the child // descriptions. Registered under (section, key) either way. if (desc.is()) { meta_out.push_back({section, key, extract_metadata(desc.as())}); - // A leaf may also declare an on_change callback. Attach it to the bound entry so a menu edit (or the + // A leaf may also declare an onChange callback. Attach it to the bound entry so a menu edit (or the // mod's own write) of this setting notifies the mod in Lua. if (bound_entry) { - sol::object on_change = desc.as()["on_change"]; + sol::object on_change = desc.as()["onChange"]; if (on_change.is()) { attach_on_change(bound_entry, on_change.as()); diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 41090da..37bd429 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -21,8 +21,8 @@ namespace big::mod_settings // before that point, while some settings only apply to a live run. The settings menu greys a row (read-only, with a // note) when the current context does not match: any: editable anywhere (default live-read settings). main_menu: // only from the main menu (greyed while a save is loaded). Forced for a mod's master "enabled" toggle and for any - // restart_required setting. in_save: only while a save is loaded (greyed at the main menu). Authors declare this - // per setting via `editable_context = "main_menu" | "in_save" | "any"`. + // restartRequired setting. in_save: only while a save is loaded (greyed at the main menu). Authors declare this + // per setting via `editableContext = "mainMenu" | "inSave" | "any"`. enum class editable_context { any, From 43e1d93272eacd1114ec47ce09c7b827e78a6521 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:29:56 +0100 Subject: [PATCH 050/100] Hide mod config keys that have no configDesc entry --- docs/mod_settings/README.md | 7 ++++ docs/mod_settings/config_schema.lua | 4 +++ src/hades2/mod_settings/config_api.cpp | 43 ++++++++++++++++++++---- src/hades2/mod_settings/mod_settings.cpp | 39 +++++++++++++++++++++ src/hades2/mod_settings/mod_settings.hpp | 5 +++ 5 files changed, 92 insertions(+), 6 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index 2928c95..ee997db 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -6,6 +6,13 @@ their settings look and read/write their values through a `config.lua` that retu - `config` - the default values (and the live values once loaded). - `configDesc` - the description/metadata for each setting (labels, help text, ranges, enums, ...). +> **Only keys with a `configDesc` entry are shown.** A key present in `config` but absent from `configDesc` +> is treated as internal state and is not displayed in the menu (a group whose keys are all undescribed +> produces no row at all). A `configDesc` entry can be either a metadata table or a plain description string - +> either counts as "described". The one exception is the mod's master `enabled` toggle, which is always shown +> so the mod stays toggleable even when it is not described. Reset to defaults likewise only affects the +> keys the menu shows. + This folder ships [LuaCATS](https://luals.github.io/wiki/annotations/) definitions ([`config_schema.lua`](./config_schema.lua)) so that VS Code gives you **autocomplete** and **hover documentation** while you write `configDesc`, plus **field type checking** on settings you annotate diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 343a5c3..87de6da 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -81,4 +81,8 @@ --- Each entry in `configDesc` can be a simple key:description string, a setting description table, an action --- button, or a nested table of descriptions mirroring a config group. The underlying .cfg file contents are --- not changed by this format. +--- +--- Only keys with a `configDesc` entry are shown in the menu: a `config` key with no entry here is treated as +--- internal state and hidden (a group whose keys are all undescribed produces no row). The mod's master +--- `enabled` toggle is always shown regardless, so the mod stays toggleable. ---@alias mod_settings.config_desc table diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 70c3be8..7ab4e7a 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -47,6 +47,11 @@ namespace big::mod_settings // (guid + '\0' + section + '\0' + key). static std::map g_setting_default; + // (section, key) pairs that carry a configDesc entry (a description string or a rich table). A config key with no + // configDesc entry is not shown in the menu, except the mod's master "enabled" toggle (always shown so the mod + // stays toggleable). Keyed the same way as g_setting_metadata (guid + '\0' + section + '\0' + key). + static std::set g_described_keys; + // Guids of mods that called rom.mod_settings.opt_out(), i.e. asked not to be configured through the in-game menu. // Guarded by g_metadata_mutex. Cleared and rebuilt on each Lua-state init (see bind_config_api) because opt_out // re-runs with each mod's main.lua. @@ -90,6 +95,10 @@ namespace big::mod_settings { it = (it->first.rfind(prefix, 0) == 0) ? g_setting_default.erase(it) : std::next(it); } + for (auto it = g_described_keys.begin(); it != g_described_keys.end();) + { + it = (it->rfind(prefix, 0) == 0) ? g_described_keys.erase(it) : std::next(it); + } } bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key) @@ -110,6 +119,14 @@ namespace big::mod_settings return it->second; } + // True if (section, key) carries a configDesc entry (any form: a description string, a setting/action table, or a + // group table). The menu shows only described keys; an undescribed config key is hidden (see build_mod_settings). + bool setting_is_described(const std::string& guid, const std::string& section, const std::string& key) + { + std::scoped_lock lock(g_metadata_mutex); + return g_described_keys.contains(metadata_key(guid, section, key)); + } + int get_setting_appearance_order(const std::string& guid, const std::string& section, const std::string& key) { std::scoped_lock lock(g_metadata_mutex); @@ -739,10 +756,11 @@ namespace big::mod_settings // Recursively binds a config.lua `defaults` table into `cf` under `section`, forwarding each leaf's description. // Nested tables become sub-sections ("section.key"). Each flat leaf whose description is a rich table has its - // metadata extracted into `meta_out` (keyed by section+key). config_file::bind adopts a value already saved in the - // .cfg, preserving user edits, and binds under section "config", so the .cfg stays byte-compatible with what - // SGG_Modding-Chalk wrote. - static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector& meta_out, std::vector>& defaults_out) + // metadata extracted into `meta_out` (keyed by section+key), and every leaf that carries any configDesc entry (a + // string or a table) is recorded in `described_out` so the menu can hide undescribed keys. config_file::bind + // adopts a value already saved in the .cfg, preserving user edits, and binds under section "config", so the .cfg + // stays byte-compatible with what SGG_Modding-Chalk wrote. + static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector& meta_out, std::vector>& defaults_out, std::vector>& described_out) { sol::table desc_tbl; const bool has_desc = desc_obj.is(); @@ -764,6 +782,7 @@ namespace big::mod_settings { desc = desc_tbl[key]; } + const bool described = desc.get_type() != sol::type::lua_nil && desc.get_type() != sol::type::none; const sol::type vt = value_obj.get_type(); std::optional default_any; @@ -771,7 +790,7 @@ namespace big::mod_settings switch (vt) { case sol::type::table: - bind_defaults(cf, value_obj.as(), desc, section + "." + key, meta_out, defaults_out); + bind_defaults(cf, value_obj.as(), desc, section + "." + key, meta_out, defaults_out, described_out); break; case sol::type::boolean: bound_entry = cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); @@ -793,6 +812,13 @@ namespace big::mod_settings if (default_any) { defaults_out.emplace_back(section, key, toml_v2::toml_type_converter::convert_to_string(*default_any)); + + // Record a described leaf so the menu shows it; an undescribed leaf is hidden. Only leaves reach here + // (default_any is set for bool/number/string, not a group table). + if (described) + { + described_out.emplace_back(section, key); + } } // A rich description table carries metadata. For a leaf it is the setting's metadata. For a nested group (a @@ -879,9 +905,10 @@ namespace big::mod_settings // setting's metadata, then persist the file. std::vector collected; std::vector> collected_defaults; // (section. + std::vector> collected_described; // (section, key) with a desc if (defaults.is()) { - bind_defaults(cf.get(), defaults.as(), descriptions, "config", collected, collected_defaults); + bind_defaults(cf.get(), defaults.as(), descriptions, "config", collected, collected_defaults, collected_described); } cf->save(); @@ -951,6 +978,10 @@ namespace big::mod_settings { g_setting_default[metadata_key(guid, section, key)] = std::move(serialized); } + for (const auto& [section, key] : collected_described) + { + g_described_keys.insert(metadata_key(guid, section, key)); + } int rank = 0; for (const auto& [off, section, key] : by_offset) { diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 055b693..5c7ce44 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2184,6 +2184,16 @@ namespace big::mod_settings return big::string::to_lower(key) == "enabled"; } + // True if a config entry carries an author-written description string. Chalk stores each configDesc entry's plain + // description directly on the bound entry (config:bind(section, key, value, description)), so this is how a + // Chalk-only mod (which never calls rom.mod_settings.load) signals that a key is described. Our own loader also + // writes the description here for string / `description`-field descs, and additionally records metadata-only descs + // in g_described_keys, so the two checks together recognize every configDesc form as "described". + static bool entry_has_description(const toml_v2::config_file::config_entry_base* entry) + { + return entry && !entry->m_description.m_description.empty(); + } + // True when the options screen was opened during gameplay (a save is loaded), false when opened from the main menu. // Captured from the MiscSettingsScreen constructor's "opened from" argument (see hook_MiscSettingsScreen_ctor). // Used to grey out context-restricted setting rows. @@ -2312,6 +2322,18 @@ namespace big::mod_settings if (key.m_section == section) { + // Hide config keys that carry no configDesc entry, so a mod's internal or bookkeeping values do not + // clutter its settings page. A key counts as described if it has metadata / a description from our + // loader (g_described_keys) or a plain description string bound by Chalk (entry_has_description). + // The one exception is the master "enabled" toggle, always shown so the mod stays toggleable even + // when its author did not describe it. + const bool is_enabled_toggle = key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key); + if (!is_enabled_toggle && !setting_is_described(stem, key.m_section, key.m_key) + && !entry_has_description(entry.get())) + { + continue; + } + panel_item it; it.key = key.m_key; it.entry = entry.get(); @@ -2325,6 +2347,14 @@ namespace big::mod_settings } else if (key.m_section.rfind(section_prefix, 0) == 0) { + // An undescribed descendant contributes nothing: it neither shows as a row inside the group nor + // ranks the group, so a subtree of only undescribed keys produces no group row at all. A Chalk + // plain-string description counts as described too (entry_has_description). + if (!setting_is_described(stem, key.m_section, key.m_key) && !entry_has_description(entry.get())) + { + continue; + } + // A descendant section: the direct child under `section` is the first path segment after the // prefix. Collapse its whole subtree into one group row, ranked by its earliest-defined descendant. const std::string rest = key.m_section.substr(section_prefix.size()); @@ -3496,6 +3526,15 @@ namespace big::mod_settings } auto* e = entry.get(); + // Reset only the settings the menu actually shows: described keys (from our loader or a Chalk + // plain-string description) plus the always-shown master "enabled" toggle. Hidden (undescribed) keys + // are the mod's internal state, so a menu Reset leaves them untouched. + const bool is_enabled_toggle = def.m_section == root_section && e->type() == typeid(bool) && is_enabled_key(def.m_key); + if (!is_enabled_toggle && !setting_is_described(guid, def.m_section, def.m_key) && !entry_has_description(e)) + { + continue; + } + auto def_val = get_setting_default(guid, def.m_section, def.m_key); if (!def_val) { diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 37bd429..8cf4266 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -86,6 +86,11 @@ namespace big::mod_settings // (in which case the menu renders it with type-based defaults). std::optional get_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); + // True if (section, key) carries a configDesc entry (a description string or a table). The menu shows only + // described keys; an undescribed config key is hidden, so a mod's internal/bookkeeping config values do not clutter + // the settings page. The mod's master "enabled" toggle is always shown regardless (handled in build_mod_settings). + bool setting_is_described(const std::string& guid, const std::string& section, const std::string& key); + // Like get_setting_metadata, but re-evaluates the setting's dynamic (Lua-function) description fields against the // current game state, returning up-to-date values (slider bounds, enum options, hidden, display name, ...). Call // this (on the game thread, while the Lua state is alive) when get_setting_metadata reports has_dynamic. The From 799cdb748cbe905d829c638bcf042db01663c0f0 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:39:49 +0100 Subject: [PATCH 051/100] Keep the current scroll page when resetting a mod's settings --- src/hades2/mod_settings/mod_settings.cpp | 27 +++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 5c7ce44..5998bf4 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -493,7 +493,6 @@ namespace big::mod_settings static View g_pending_view = View::mod_list; static std::string g_pending_stem; static std::string g_pending_section; - static bool g_nav_reset_to_top = false; // Reset action: force a top (non-instant) rebuild next apply_nav. // Identifies a panel row by its stable fields (kind + owning mod + section + key) so it can be matched to the // equivalent freshly built row after a rebuild frees every component. @@ -3435,11 +3434,9 @@ namespace big::mod_settings // keeps the fade-in transition. static void apply_nav(MiscSettingsScreen* screen) { - // A Reset forces a top (non-instant) rebuild even though the view is unchanged, so the restored rows and the - // scrollbar stay in sync - an in-place rebuild that preserves a scrolled position would leave the stale page-1 - // rows visible (see the scroll-model notes). - const bool instant = !g_nav_reset_to_top && (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); - g_nav_reset_to_top = false; + // A rebuild that stays on the same view/mod/section (a setting edit, an "enabled" toggle, or a Reset) is + // applied instantly, which preserves the current scroll page instead of snapping back to the top. + const bool instant = (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); // Maintain the restore stack. A drill-in step (the mod list into a mod, or a section into a deeper child // section) pushes the parent's scroll offset plus the identity of the row being drilled through A back step (a @@ -3556,18 +3553,19 @@ namespace big::mod_settings } // Handles a Reset activation on the Mods tab: restores the in-scope settings to their config.lua defaults, then (in - // a mod's settings view, where the changed values are on screen) queues a top rebuild so the widgets show the - // restored values. Safe to call from input/click context because the rebuild is deferred to the Update hook. + // a mod's settings view, where the changed values are on screen) queues an in-place rebuild so the widgets show the + // restored values. The rebuild is instant (same view/mod/section), so it preserves the current scroll page and a + // Reset never jumps back to the first page. Safe to call from input/click context because the rebuild is deferred + // to the Update hook. static void perform_reset() { const bool changed = reset_settings_to_defaults(); if (changed && g_view == View::mod_settings) { - g_pending_view = g_view; - g_pending_stem = g_view_stem; - g_pending_section = g_view_section; - g_nav_pending = true; - g_nav_reset_to_top = true; + g_pending_view = g_view; + g_pending_stem = g_view_stem; + g_pending_section = g_view_section; + g_nav_pending = true; } } @@ -3829,8 +3827,7 @@ namespace big::mod_settings g_view_stem.clear(); g_view_section.clear(); g_pending_section.clear(); - g_nav_pending = false; - g_nav_reset_to_top = false; + g_nav_pending = false; g_nav_stack.clear(); g_has_pending_restore = false; g_restart_required = false; From 0d03ad1884d93f08969acde7fa80668eda8df18c Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:19:36 +0100 Subject: [PATCH 052/100] Add inHub editable context for hub-only mod settings --- docs/mod_settings/README.md | 2 +- docs/mod_settings/config_schema.lua | 8 ++++---- src/hades2/mod_settings/config_api.cpp | 23 +++++++++++++++++++++-- src/hades2/mod_settings/mod_settings.cpp | 18 ++++++++++++++---- src/hades2/mod_settings/mod_settings.hpp | 13 +++++++++++-- 5 files changed, 51 insertions(+), 13 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index ee997db..18d3174 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -41,7 +41,7 @@ Hover any field in the editor for its documentation. The available fields on a s | `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. See below. | | `freetext` | boolean | Force a bounded number to be a free-text entry instead of a slider. | | `restartRequired` | boolean | Force the user to restart the game when this setting is changed. | -| `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` | If this setting can be changed only in the main menu, only in a save, or in both. When the current context does not match, the row is shown read-only with a note. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. Defaults to `"any"`. | +| `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict when this setting can be changed: `"any"` (default), `"mainMenu"` (only from the main menu), `"inSave"` (only while a save is loaded - both in the Crossroads and mid-run), or `"inHub"` (only while in the Crossroads). When the current context does not match, the row is shown read-only with a note. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | | `showAsPercentage` | boolean | Append "%" to the value. | | `isPercentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | | `onChange` | `fun(key, new_value)` | Called after the setting is changed in the in-game menu. Use it to apply the change to the loaded run. See below. | diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 87de6da..6cf6ab7 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -48,10 +48,10 @@ --- Mark that changing this setting requires a game restart. The menu forces the player --- to restart when they leave the mod menu after changing it. ---@field restartRequired? boolean ---- If this setting can be changed only in the main menu, only in a save, or in both. +--- If this setting can be changed only in the main menu, only in a save (run or Crossroads), only in the Crossroads, or anywhere. --- When the current context does not match, the row is shown read-only with a note. --- The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. ----@field editableContext? "any" | "mainMenu" | "inSave" +---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" --- Append "%" to the displayed value. ---@field showAsPercentage? boolean --- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). @@ -72,8 +72,8 @@ ---@field description? mod_settings.dynamic_string --- Sort key among the section's rows, lower first. ---@field order? mod_settings.dynamic_number ---- When the button is activated: only in the main menu, only in a save, or both. ----@field editableContext? "any" | "mainMenu" | "inSave" +--- When the button is activated: only in the main menu, only in a save (run or Crossroads), only in the Crossroads, or anywhere. +---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" --- Grey the button out (non-interactive) while this is true. Updates live while the menu is open (e.g. --- grey an "Apply" button until a value has actually changed). ---@field disabled? mod_settings.dynamic_boolean diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 7ab4e7a..7a4f0c6 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -271,8 +271,8 @@ namespace big::mod_settings return flag.is() && flag.as(); } - // Parses an `editableContext` field ("any"/"mainMenu"/"inSave") returns `fallback` for anything else. Shared by - // setting metadata and action buttons. + // Parses an `editableContext` field ("any"/"mainMenu"/"inSave"/"inHub") returns `fallback` for anything else. + // Shared by setting metadata and action buttons. static editable_context parse_editable_context(const sol::object& o, editable_context fallback) { if (o.get_type() == sol::type::string) @@ -286,6 +286,10 @@ namespace big::mod_settings { return editable_context::in_save; } + if (s == "inHub") + { + return editable_context::in_hub; + } if (s == "any") { return editable_context::any; @@ -1012,6 +1016,21 @@ namespace big::mod_settings return m; } + // True when the game is in the hub (the Crossroads): the game Lua global `CurrentHubRoom` is non-nil (the game sets + // it to the current hub room while in the hub and clears it during a run). Reads the game's Lua state directly (the + // same state mods run in, where `_G` is the game globals - see hades_lua.hpp), so it must be called on the game + // thread while the state is alive. Returns false when the Lua manager is not up yet. + bool game_is_in_hub() + { + if (!big::g_lua_manager) + { + return false; + } + sol::state_view state = big::g_lua_manager->lua_state(); + const sol::object chr = state["CurrentHubRoom"]; + return chr.get_type() != sol::type::lua_nil && chr.get_type() != sol::type::none; + } + std::vector get_actions(const std::string& guid, const std::string& section) { std::vector result; diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 5998bf4..90c0e2c 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2198,6 +2198,12 @@ namespace big::mod_settings // Used to grey out context-restricted setting rows. static bool g_opened_in_game = false; + // True when the game global `CurrentHubRoom` is non-nil, i.e. the player is in the hub (the Crossroads) rather than + // in a run. Captured once in the ctor (see hook_MiscSettingsScreen_ctor) via game_is_in_hub() - the context cannot + // change while the pause screen is open. Combined with g_opened_in_game (a stale CurrentHubRoom at the main menu is + // then still safe) it gates `editableContext = "inHub"` rows. + static bool g_in_hub = false; + // True while a native options screen is open (set in the ctor,. Cleared when it actually closes in ExitScreen). // Combined with g_opened_in_game it gates on_change callbacks so they fire only for a setting changed through the @@ -2248,15 +2254,16 @@ namespace big::mod_settings return meta ? meta->context : editable_context::any; } - // True when a setting cannot be changed in the current screen context (main-menu vs in-game), so its row is shown - // read-only with an explanatory note instead of an editable widget. + // True when a setting cannot be changed in the current screen context (main-menu vs in-game vs in-hub), so its row + // is shown read-only with an explanatory note instead of an editable widget. static bool is_context_restricted(editable_context ctx) { switch (ctx) { case editable_context::main_menu: return g_opened_in_game; // main-menu-only, greyed while in a save. case editable_context::in_save: return !g_opened_in_game; // in-save-only, greyed at the main menu. - default: return false; // any. + case editable_context::in_hub: return !(g_opened_in_game && g_in_hub); // hub-only, greyed at menu / mid-run. + default: return false; // any. } } @@ -2268,6 +2275,7 @@ namespace big::mod_settings { case editable_context::main_menu: return "This setting can only be changed from the main menu."; case editable_context::in_save: return "This setting can only be changed while a save is loaded."; + case editable_context::in_hub: return "This setting can only be changed while in the Crossroads."; default: return {}; } } @@ -3843,8 +3851,10 @@ namespace big::mod_settings // Record whether the screen was opened during gameplay (a save loaded) or from the main menu, so // context-restricted rows can be greyed. Must be set before the original ctor runs, which shows the last-viewed - // category and may build our panel via DoShowCategory. + // category and may build our panel via DoShowCategory. game_is_in_hub() further distinguishes the hub (the + // Crossroads) from a run for `editableContext = "inHub"` rows. g_opened_in_game = opener_indicates_in_game(opened_from); + g_in_hub = game_is_in_hub(); g_options_screen_open = true; // The engine constructor returns `this` forward it unchanged diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 8cf4266..62ff725 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -21,13 +21,16 @@ namespace big::mod_settings // before that point, while some settings only apply to a live run. The settings menu greys a row (read-only, with a // note) when the current context does not match: any: editable anywhere (default live-read settings). main_menu: // only from the main menu (greyed while a save is loaded). Forced for a mod's master "enabled" toggle and for any - // restartRequired setting. in_save: only while a save is loaded (greyed at the main menu). Authors declare this - // per setting via `editableContext = "mainMenu" | "inSave" | "any"`. + // restartRequired setting. in_save: only while a save is loaded, both in the hub and mid-run (greyed at the main + // menu). in_hub: only while in the hub / Crossroads (greyed at the main menu AND mid-run), for settings unsafe to + // change during a run. Authors declare this per setting via + // `editableContext = "mainMenu" | "inSave" | "inHub" | "any"`. enum class editable_context { any, main_menu, in_save, + in_hub, }; // Author-declared metadata for a single setting, extracted from its config.lua description table by @@ -91,6 +94,12 @@ namespace big::mod_settings // the settings page. The mod's master "enabled" toggle is always shown regardless (handled in build_mod_settings). bool setting_is_described(const std::string& guid, const std::string& section, const std::string& key); + // True when the game is currently in the hub (the Crossroads): the game Lua global `CurrentHubRoom` is non-nil. + // Reads the game's Lua state (shared with mods), so it must be called on the game thread while the state is alive. + // The settings menu uses it to gate `editableContext = "inHub"` rows (editable only in the hub, not mid-run). + // Returns false when the Lua state is unavailable. + bool game_is_in_hub(); + // Like get_setting_metadata, but re-evaluates the setting's dynamic (Lua-function) description fields against the // current game state, returning up-to-date values (slider bounds, enum options, hidden, display name, ...). Call // this (on the game thread, while the Lua state is alive) when get_setting_metadata reports has_dynamic. The From 7599361adc04a8b2ff56b52a32aa4b4693c54c57 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:58:23 +0100 Subject: [PATCH 053/100] Add disabledDescription and show editableContext note in hint texts --- docs/mod_settings/README.md | 16 +++- docs/mod_settings/config_schema.lua | 8 ++ src/hades2/mod_settings/config_api.cpp | 14 +++- src/hades2/mod_settings/mod_settings.cpp | 98 ++++++++++++++++++++---- src/hades2/mod_settings/mod_settings.hpp | 17 ++-- src/hades2/mod_settings/sgg_gui.hpp | 6 ++ 6 files changed, 134 insertions(+), 25 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index 18d3174..59e440c 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -39,6 +39,7 @@ Hover any field in the editor for its documentation. The available fields on a s | `order` | number \| callback | Sort key for custom ordering config entries in the menu, lower first. | | `hidden` | boolean | Hide the setting from the menu entirely. Static only - use `disabled` for a condition that changes while the menu is open. | | `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. See below. | +| `disabledDescription` | string \| localization table \| callback | Description shown in place of `description` while the setting is greyed by its own `disabled` field, to explain why. Falls back to `description` when omitted. Not used for context-restricted or mod-disabled rows. | | `freetext` | boolean | Force a bounded number to be a free-text entry instead of a slider. | | `restartRequired` | boolean | Force the user to restart the game when this setting is changed. | | `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict when this setting can be changed: `"any"` (default), `"mainMenu"` (only from the main menu), `"inSave"` (only while a save is loaded - both in the Crossroads and mid-run), or `"inHub"` (only while in the Crossroads). When the current context does not match, the row is shown read-only with a note. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | @@ -51,7 +52,7 @@ Hover any field in the editor for its documentation. The available fields on a s Most fields can also be dynamically resolved through a function call, which is evaluated when the menu is opened and refreshed (after any other setting is changed). This lets a setting react to the live game state or to other settings. The following may be a **function** returning the value instead of a -literal: `displayName`, `description`, `min`, `max`, `step`, `values`, `labels`, `order`, and +literal: `displayName`, `description`, `disabledDescription`, `min`, `max`, `step`, `values`, `labels`, `order`, and `disabled`. The function runs in your mod's environment, so it can read your `config`, and call functions in your `mod` or the `game` namespace. @@ -67,17 +68,24 @@ meta_reward_fix_chance_cap = { displayName = "Meta Reward Chance Cap", min = 30, max = 90, disabled = function() return not mod.config.meta_reward_fix end, -- greyed unless the fix toggle is on + disabledDescription = "Enable \"Fix Meta Reward Count\" above to change this.", -- shown while greyed }, ``` Use `disabled` (greys the row in place) for a condition that changes while the menu is open. `hidden` is -static only - it is evaluated only when the menu builds, and cannot be changed dynamically. +static only - it is evaluated only when the menu builds, and cannot be changed dynamically. Pair `disabled` +with `disabledDescription` to explain why the row is greyed: while the row is disabled by its own `disabled` +field, the description box shows `disabledDescription` instead of the normal `description` (falling back to +`description` if you omit it). A greyed row still highlights on mouse hover so the note is readable. This does +not apply to context-restricted rows (which show their own "change it in X" note) or while the whole mod is +disabled. ## Action buttons A `configDesc` entry with an `action` function (and a key that has NO config value) renders as a button that runs the callback when pressed, instead of editing a setting. It supports `displayName`, `description`, -`order`, `editableContext`, and `disabled` (grey the button live, e.g. until a value has changed). +`disabledDescription`, `order`, `editableContext`, and `disabled` (grey the button live, e.g. until a value +has changed). ```lua apply_scaling = { @@ -85,6 +93,8 @@ apply_scaling = { displayName = "Apply Late Biome Scaling", description = "Apply the scaling values above to the current run.", editableContext = "inSave", -- greyed unless a save is loaded + disabled = function() return not mod.HasUnappliedScaling() end, -- greyed until a value changes + disabledDescription = "Change a scaling value above to enable this.", -- shown while greyed }, ``` diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 6cf6ab7..6198d23 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -43,6 +43,10 @@ --- Grey the setting out (shown read-only, cannot be changed) while this is true. Unlike `hidden`, a `disabled` --- change updates live while the menu is open (e.g. grey a slider unless its parent toggle is enabled). ---@field disabled? mod_settings.dynamic_boolean +--- Description shown in place of `description` while the setting is greyed by its own `disabled` field, to +--- explain why it is unavailable. Ignored for a context-restricted row (only editable in main menu etc.) or +--- while the whole mod is disabled. Defaults to the normal `description` when omitted. +---@field disabledDescription? mod_settings.dynamic_string --- Force a bounded number (one with `min` and `max`) to a free-text text field instead of a slider. ---@field freetext? boolean --- Mark that changing this setting requires a game restart. The menu forces the player @@ -77,6 +81,10 @@ --- Grey the button out (non-interactive) while this is true. Updates live while the menu is open (e.g. --- grey an "Apply" button until a value has actually changed). ---@field disabled? mod_settings.dynamic_boolean +--- Description shown in place of `description` while the setting is greyed by its own `disabled` field, to +--- explain why it is unavailable. Ignored for a context-restricted row (only editable in main menu etc.) or +--- while the whole mod is disabled. Defaults to the normal `description` when omitted. +---@field disabledDescription? mod_settings.dynamic_string --- Each entry in `configDesc` can be a simple key:description string, a setting description table, an action --- button, or a nested table of descriptions mirroring a config group. The underlying .cfg file contents are diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 7a4f0c6..d45d522 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -341,6 +341,11 @@ namespace big::mod_settings sol::object display_name = desc["displayName"]; m.name = parse_localized(display_name); + // Alternative description shown while the row is greyed by its `disabled` field (empty -> fall back to the + // normal description). String or localization table, like displayName. If it was written as a function it has + // already been resolved to a concrete value by resolve_description before this runs. + m.disabled_description = parse_localized(desc["disabledDescription"]); + sol::object min_field = desc["min"]; if (min_field.get_type() == sol::type::number) { @@ -427,7 +432,7 @@ namespace big::mod_settings // intentionally NOT dynamic: showing/hiding a row shifts the layout and the row set is only re-evaluated on a // full rebuild, so a live-changing condition must use `disabled` instead. `editableContext` is a fixed design // property of a setting, so it is static too. - for (const char* field : {"displayName", "description", "min", "max", "step", "values", "labels", "order", "disabled"}) + for (const char* field : {"displayName", "description", "disabledDescription", "min", "max", "step", "values", "labels", "order", "disabled"}) { if (desc[field].get_type() == sol::type::function) { @@ -548,8 +553,9 @@ namespace big::mod_settings // Reads the static (non-function) action metadata common to collection and dynamic re-resolution. static void read_action_fields(const sol::table& entry, action_info& a) { - a.name = parse_localized(entry["displayName"]); - a.description = describe(entry); + a.name = parse_localized(entry["displayName"]); + a.description = describe(entry); + a.disabled_description = parse_localized(entry["disabledDescription"]); if (sol::object o = entry["order"]; o.get_type() == sol::type::number) { a.has_order = true; @@ -586,7 +592,7 @@ namespace big::mod_settings a.section = section; a.key = k.as(); read_action_fields(entry, a); - for (const char* field : {"displayName", "description", "order", "disabled"}) + for (const char* field : {"displayName", "description", "disabledDescription", "order", "disabled"}) { if (entry[field].get_type() == sol::type::function) { diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 90c0e2c..b7122d7 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2280,6 +2280,24 @@ namespace big::mod_settings } } + // Description-box text for a context-restricted (editableContext-blocked) row: the scenario note on the first + // line(s), then the row's normal description below it, so the box explains BOTH why the row is read-only here and + // what it does. Either part may be empty (an empty note or description collapses to just the other). The break is a + // '\n', handled like the restart dialog's build_list_message; sync_description_box configures the box to honor it + // while keeping automatic word-wrap of each part. + static std::string note_then_description(const std::string& note, const std::string& description) + { + if (note.empty()) + { + return description; + } + if (description.empty()) + { + return note; + } + return note + "\n" + description; + } + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render // as setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). Each direct child // section renders as a group row that drills into it. At the root section a boolean "enabled" entry (if present) is @@ -2466,24 +2484,56 @@ namespace big::mod_settings g_view_has_dynamic = true; } const bool ctx_blocked = is_context_restricted(it.action.context); - const bool act_disabled = disabled || it.action.disabled || ctx_blocked; + const bool mod_off = disabled; // the whole mod is disabled + const bool act_disabled = mod_off || it.action.disabled || ctx_blocked; const std::string name = resolve_localized(it.action.name); const std::string label = escape_markup(name.empty() ? key_to_display(it.key) : name); - if (auto* row = make_button_row(screen, label.c_str(), act_disabled, /*block_input*/ act_disabled)) + + // A mod-off action is hard-disabled (block_input); an author-disabled or context-blocked action is only + // greyed (block_input=false), so it stays focusable/hoverable to show its note - clicks are still + // blocked by pr.disabled in the OnClicked hook. This mirrors context-restricted settings. + if (auto* row = make_button_row(screen, label.c_str(), act_disabled, /*block_input*/ mod_off)) { - // A greyed action button is fully inert: not hoverable, not selectable, not clickable (unlike a - // context-restricted setting, which stays focusable to show its note). Clearing mSelectable makes. - // MenuScreen::SetMouseOver. Skip it entirely, so it never highlights or takes the selection - // m_can_be_focused = false blocks focus too. - if (act_disabled) + // A mod-off action button is fully inert: clearing mSelectable makes MenuScreen::SetMouseOver skip + // it so it never highlights or takes the selection, and m_can_be_focused = false blocks keyboard + // focus too. A soft-disabled action keeps both so it can be highlighted (mouse) to read its note. + if (mod_off) { row->m_can_be_focused = false; *reinterpret_cast(reinterpret_cast(row) + sgg::gui_component_button_selectable_offset) = false; } + else + { + // Soft-disabled (author-disabled or context-blocked) but kept useable so it can be highlighted + // to show its note. Clear the hover + selection overlays so it does not flash a clickable + // glow on mouse-over (the grey label already signals it is disabled, like a text row). + *reinterpret_cast(reinterpret_cast(row) + sgg::gui_component_button_under_mouse_texture_offset) = 0; + if (g_set_selected_texture) + { + g_set_selected_texture(row, 0); + } + } PanelRow pr{row, RowKind::action, stem, it.key}; pr.disabled = act_disabled; pr.target_section = it.action.section; // the section the action's callback lives in. - pr.description = ctx_blocked ? context_note(it.action.context) : resolve_localized(it.action.description); + + // A context mismatch shows the scenario note first, then the normal description below it; an + // author-disabled action shows its disabledDescription (falling back to the normal description) so + // the author can explain why it is greyed. + if (ctx_blocked) + { + pr.description = + note_then_description(context_note(it.action.context), resolve_localized(it.action.description)); + } + else if (it.action.disabled) + { + const std::string ddesc = resolve_localized(it.action.disabled_description); + pr.description = !ddesc.empty() ? ddesc : resolve_localized(it.action.description); + } + else + { + pr.description = resolve_localized(it.action.description); + } g_rows.push_back(std::move(pr)); } continue; @@ -2611,9 +2661,18 @@ namespace big::mod_settings pr.is_enabled_toggle = is_enabled_row; pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); - // Context mismatch shows where to change it an author-disabled row shows its normal description - // (disabled_description support is a separate task). - pr.description = context_blocked ? context_note(ctx) : (meta ? resolve_localized(meta->description) : std::string{}); + // A context mismatch shows the scenario note first, then the normal description below it; an + // author-disabled row shows its disabledDescription (falling back to the normal description) so the + // author can explain why it is greyed. + if (context_blocked) + { + pr.description = note_then_description(context_note(ctx), meta ? resolve_localized(meta->description) : std::string{}); + } + else + { + const std::string ddesc = meta ? resolve_localized(meta->disabled_description) : std::string{}; + pr.description = !ddesc.empty() ? ddesc : (meta ? resolve_localized(meta->description) : std::string{}); + } g_rows.push_back(pr); } continue; @@ -2870,8 +2929,21 @@ namespace big::mod_settings { g_last_description_component = active; - // Escape markup so paths/brackets in the description render verbatim (see escape_markup). - const std::string shown = show ? escape_markup(*description) : std::string{}; + // Escape markup so paths/brackets in the description render verbatim (see escape_markup), then turn any + // embedded newline into the box's hard-break escape. GUIComponentTextBox::Parse strips a raw 0x0A but + // honors the escape "\n" (backslash + n) as a wrap-independent hard break (via ParseEscapeSequence), so a + // context note stays on its own line above the description while each part still word-wraps. The escape is + // inserted AFTER escape_markup, which would otherwise double its backslash into a literal "\n". Padded + // " \n " like the engine's own AddLineBreak so the surrounding whitespace is eaten cleanly. + std::string shown; + if (show) + { + shown = escape_markup(*description); + for (std::size_t pos = 0; (pos = shown.find('\n', pos)) != std::string::npos; pos += 4) + { + shown.replace(pos, 1, " \\n "); + } + } g_show_text(box, shown.c_str()); // ShowText only marks the lines dirty. The layout (and text height, which the box's justification uses to diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 62ff725..bca04ab 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -43,6 +43,12 @@ namespace big::mod_settings localized_text name; // display-name override (empty -> prettified key) localized_text description; // same text written to the .cfg comment + // Shown in the description box in place of `description` while the row is greyed by its own `disabled` field, + // so the author can explain why it is unavailable. Empty -> fall back to `description`. Not applied to a + // context-restricted row (which shows its own where-to-change note) or a mod-disabled row (the off mod toggle + // already explains that). May be a plain string, a localization table, or a dynamic (function) field. + localized_text disabled_description; + bool has_min = false; double min = 0.0; bool has_max = false; @@ -112,11 +118,12 @@ namespace big::mod_settings // `order`. struct action_info { - std::string section; // config section the action lives in (drilldown level) - std::string key; // description key of the action - localized_text name; // button label (display_name, or the prettified key) - localized_text description; // help text shown while highlighted - bool has_order = false; // author-declared sort key present + std::string section; // config section the action lives in (drilldown level) + std::string key; // description key of the action + localized_text name; // button label (display_name, or the prettified key) + localized_text description; // help text shown while highlighted + localized_text disabled_description; // shown instead of `description` while author-disabled (may be dynamic) + bool has_order = false; // author-declared sort key present double order = 0.0; editable_context context = editable_context::any; // when the button is enabled (main-menu vs in-save) bool disabled = false; // greyed and non-interactive (author-declared, may be dynamic) diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp index 3de7c33..69fbc49 100644 --- a/src/hades2/mod_settings/sgg_gui.hpp +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -93,6 +93,12 @@ namespace big::mod_settings::sgg // non-hoverable and non-selectable (used to fully disable a greyed action button). inline constexpr std::size_t gui_component_button_selectable_offset = 0x5'51; + // Byte offset of GUIComponentButton::mUnderMouseTexture (sgg::TextureHandle, a 32-bit id). GUIComponentButton::Draw + // draws this hover-highlight overlay only when it is valid and mIsUseable@0x27 is set (gate at Draw+0xBF). A + // greyed-but-hoverable action (kept useable so it can show its description) clears this so it does not flash a + // clickable-looking hover glow. mSelectedTexture (selection overlay) is at 0x564 (SetSelectedTexture clears it). + inline constexpr std::size_t gui_component_button_under_mouse_texture_offset = 0x5'68; + // Byte offset of GUIComponentButton::mDisplayNameId (sgg::HashGuid: a 32-bit interned-string id). The engine // derives a button's visible label from this id:. GUIComponentButton::UseDefaultText resolves the id back to its // interned string, looks that up in the localized text data, and sets the label from the result (falling back to From e8183e115437bb552a4aec6702a8fa6b260c0c2f Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:20:46 +0100 Subject: [PATCH 054/100] Make the mod settings menu resilient to game updates --- src/hades2/mod_settings/mod_settings.cpp | 91 ++++++++++++++++-------- src/hades2/pdb_symbol_map.hpp | 6 ++ src/main.cpp | 31 ++++---- 3 files changed, 87 insertions(+), 41 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index b7122d7..e2d3a0a 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -129,20 +129,13 @@ namespace big::mod_settings static constexpr std::uintptr_t push_back_rva = 0x14'1E'D0; // sgg::MenuScreen::TeleportCursorTo(this, GUIComponent*) - the 2-arg overload that drops the controller/keyboard - // free-form cursor onto a component - and sgg::ConfigOptions::UseMouse, the global bool that is false in - // controller/keyboard mode. Both addressed by RVA off the anchor. - static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; - static constexpr std::uintptr_t config_use_mouse_rva = 0x83'69'15; + // free-form cursor onto a component. Addressed by RVA off the anchor. + static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; - // sgg::ConfigOptions::Language: eastl string holding the current display-language code (e.g. "en", "zh-TW"). Used - // to pick text/blank characters the current locale's font can render. - static constexpr std::uintptr_t config_language_rva = 0x83'69'20; - - // &sgg::Controls::Cancel (the remappable. Back/CancelBack/Cancel action that folds together controller B and - // keyboard Esc) and &sgg::Controls::Select (controller A + Enter). Their first int is the id indexing - // InputHandler's control-state array. - static constexpr std::uintptr_t config_cancel_rva = 0x55'12'20; - static constexpr std::uintptr_t config_select_rva = 0x55'1D'80; + // The config/control GLOBALS below (ConfigOptions::UseMouse / ConfigOptions::Language / Controls::Cancel / + // Controls::Select) used to be addressed by RVA off the anchor too, but they live in .data/.rdata, which a game + // update can grow and shift independently of .text, so an anchor-relative RVA cannot be trusted for them. They + // are named PDB globals, so they are now resolved by name (update-proof) - see set_up_hooks. // sgg::GUIComponentNumBox field offsets (DIA-validated on the current Ship build) sizeof 0x5D0. Derives directly // from GUIComponent (not GUIComponentButton). @@ -201,7 +194,10 @@ namespace big::mod_settings // fields) - the value is mFraction and the fill graphic redraws from it. The game has no factory for it // (DoShowCategory hand-rolls the allocation + the four sub-components), so make_slider_row replicates that // construction. - static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'48; // ??_7GUIComponentSlider@sgg@@6B@ (off the anchor). + // ??_7GUIComponentSlider@sgg@@6B@. Preferred by name at runtime (see set_up_hooks); this anchor-relative RVA is + // only a fallback if the vtable public symbol is absent from the map. Lives in .rdata, which shifts on updates, + // so keep it in sync when refreshing for a new build. + static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'68; static constexpr std::size_t slider_sizeof = 0x5'B0; static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking / mFill) static constexpr std::size_t textbox_sizeof = 0x6'C0; // sgg::GUIComponentTextBox (mLabel / mValueTextBox) @@ -4686,7 +4682,7 @@ namespace big::mod_settings // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the number-box // stepper). The engine has no slider factory, so a slider is hand-built from the base GUIComponent / image / // text-box constructors and Defaults - all resolved by name here. SetFraction is both the initial set and the - // drag hook (installed below). The slider vtable is addressed by RVA off the anchor once the build is verified. + // drag hook (installed below). The slider vtable is resolved by name (RVA fallback) once the build is verified. g_gui_component_ctor = big::hades2_symbol_to_address["sgg::GUIComponent::GUIComponent"].as_func(); g_image_ctor = big::hades2_symbol_to_address["sgg::GUIComponentImage::GUIComponentImage"].as_func(); g_textbox_ctor = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GUIComponentTextBox"].as_func(); @@ -4695,8 +4691,8 @@ namespace big::mod_settings g_slider_set_fraction = slider_set_fraction.as_func(); // Controller focus: ComponentFocused makes a row the focused option (so the stick reaches it),. GetState reads - // the Back/Cancel control edge for our drilldown back-nav. Both by name. The Controls::Cancel address is - // RVA-relative (resolved below). Optional - their absence only degrades controller support, not the tab. + // the Back/Cancel control edge for our drilldown back-nav. Both by name (Controls::Cancel is resolved by name + // above). Optional - their absence only degrades controller support, not the tab. g_component_focused = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ComponentFocused"].as_func(); g_set_mouse_over = big::hades2_symbol_to_address["sgg::MenuScreen::SetMouseOver"].as_func(); g_input_get_state = big::hades2_symbol_to_address["sgg::InputHandler::GetState"].as_func(); @@ -4715,11 +4711,34 @@ namespace big::mod_settings g_save_profile = big::hades2_symbol_to_address["sgg::ProfileManager::SaveProfile"].as_func(); g_active_profile = big::hades2_symbol_to_address["sgg::ProfileManager::ACTIVE_PROFILE"].as(); + // Config/control GLOBALS, resolved by name (update-proof - they are named PDB data symbols that move with + // .data/.rdata across game updates, so an anchor-relative RVA cannot be trusted for them). Optional: a null + // UseMouse just makes us assume controller/keyboard mode (mouse checks are `g_use_mouse && *g_use_mouse`), a + // null language skips the locale font fallback, and null Cancel/Select only degrade controller back/select + // detection - none crash. UseMouse is the global bool that is false in controller/keyboard mode; Language is + // the eastl string with the current display-language code; Cancel/Select are the remappable Back (controller + // B / Esc) and Select (controller A / Enter) controls whose first int indexes InputHandler's state array. + g_use_mouse = big::hades2_symbol_to_address["sgg::ConfigOptions::UseMouse"].as(); + g_config_language = big::hades2_symbol_to_address["sgg::ConfigOptions::Language"].as(); + g_controls_cancel = big::hades2_symbol_to_address["sgg::Controls::Cancel"].as(); + g_controls_select = big::hades2_symbol_to_address["sgg::Controls::Select"].as(); + // The num-box factory (a template instantiation) and the restart-dialog ctor /. AddScreen overloads cannot be // picked by name from the PDB, so they are addressed by hardcoded RVA off the button-ctor anchor. Those RVAs - - // and every struct offset this feature uses - are valid only for the Ship build they were captured. Fingerprint - // that build by checking the anchor sits at its known module RVA (game base taken from the live process). A - // mismatch means the game changed and our RVAs/offsets can no longer be trusted, so disable the whole tab. + // and every struct offset this feature uses - are valid only for the Ship build they were captured against. + // Symbols resolved by name above auto-adapt across game updates, but these hardcoded values do NOT, so a game + // update can move them and hang/crash the options screen. Gate the whole menu on the exact game build via its + // PDB GUID (a unique per-build id captured while the symbol map is built): after an update the GUID no longer + // matches, and the menu is cleanly skipped (the rom.mod_settings Lua config API is unaffected) until + // Hell2Modding is updated. Only the latest build is supported: to move to a new one, re-validate the + // RVAs/offsets against it (compare the new Ship Hades2.pdb), then replace this GUID (logged at startup and in + // the warning below) with the new build's. Current build: Hades II Ship v1.139251. + static constexpr const char* validated_pdb_guid = "48ca71f9-5fbb-4209-a14a9738171ce4eb"; + const bool build_validated = big::hades2_pdb_guid == validated_pdb_guid; + + // Secondary sanity check on top of the GUID allow-list: the anchor (button ctor) must sit at its known module + // RVA. A matching GUID already implies this, so a failure here means the PDB and the loaded exe disagree (e.g. + // a mismatched/hand-swapped PDB), which would make every RVA/offset untrustworthy. uintptr_t game_base = 0; std::size_t game_size = 0; ::module_info_helper::get_module_base_and_size(&game_base, &game_size, nullptr); @@ -4735,7 +4754,7 @@ namespace big::mod_settings missing.push_back("eastl::vector::push_back"); } - if (!missing.empty() || !build_matches) + if (!missing.empty() || !build_matches || !build_validated) { std::string detail; for (const auto* name : missing) @@ -4743,9 +4762,16 @@ namespace big::mod_settings detail += "\n - missing symbol: "; detail += name; } + if (!build_validated) + { + detail += "\n - game build not validated for this Hell2Modding version (PDB GUID '"; + detail += big::hades2_pdb_guid.empty() ? "" : big::hades2_pdb_guid; + detail += "'). The game likely updated; update validated_pdb_guid to this GUID after re-validating the " + "engine offsets/RVAs against the new Ship build."; + } if (!build_matches) { - detail += "\n - build fingerprint mismatch (button ctor not at the expected RVA; game updated?)"; + detail += "\n - build fingerprint mismatch (button ctor not at the expected RVA; PDB/exe mismatch?)"; } LOG(WARNING) << "[mod_settings] Mods options tab disabled for this game build; the in-game mod-settings " "editor is skipped. The rom.mod_settings Lua config API is unaffected." @@ -4753,17 +4779,26 @@ namespace big::mod_settings return; } - // Build verified and every required symbol resolved: derive the RVA-relative helpers and hook. + // Build verified and every required symbol resolved: derive the remaining anchor-relative helpers and hook. + // These are all .text functions (the templated num-box factory and the overloaded MessageDialog ctor / + // AddScreen that cannot be picked unambiguously by name, plus TeleportCursorTo). The config/control globals + // are resolved by name above (they move with .data/.rdata). const auto anchor_base = anchor.as() - anchor_rva; g_message_dialog_ctor = reinterpret_cast(anchor_base + message_dialog_ctor_rva); g_add_screen = reinterpret_cast(anchor_base + add_screen_rva); g_numbox_factory = reinterpret_cast(anchor_base + numbox_factory_rva); - g_slider_vtable = anchor_base + slider_vtable_rva; g_teleport_cursor = reinterpret_cast(anchor_base + teleport_cursor_rva); - g_use_mouse = reinterpret_cast(anchor_base + config_use_mouse_rva); - g_config_language = reinterpret_cast(anchor_base + config_language_rva); - g_controls_cancel = reinterpret_cast(anchor_base + config_cancel_rva); - g_controls_select = reinterpret_cast(anchor_base + config_select_rva); + + // Slider vtable: prefer the named public symbol (update-proof), fall back to the anchor-relative RVA (which + // lives in .rdata and shifts on updates) only if the vtable is absent from the symbol map. + if (const auto slider_vt = big::hades2_symbol_to_address["??_7GUIComponentSlider@sgg@@6B@"]; slider_vt) + { + g_slider_vtable = slider_vt.as(); + } + else + { + g_slider_vtable = anchor_base + slider_vtable_rva; + } g_feature_enabled = true; diff --git a/src/hades2/pdb_symbol_map.hpp b/src/hades2/pdb_symbol_map.hpp index 308737f..2b225d8 100644 --- a/src/hades2/pdb_symbol_map.hpp +++ b/src/hades2/pdb_symbol_map.hpp @@ -8,6 +8,12 @@ namespace big inline std::unordered_map hades2_symbol_to_code_size; + // The Ship Hades2.pdb GUID (lowercase 8-4-4-16 hex, no braces), captured while the symbol map is built (see + // main.cpp). It uniquely identifies the exact game build, so features that rely on hardcoded engine RVAs / struct + // offsets (which are only valid for a validated build) can gate themselves on it and disable cleanly after a game + // update rather than reading stale addresses. Empty if the PDB was not parsed. + inline std::string hades2_pdb_guid; + // Function to insert symbols with unique names into the map inline void hades2_insert_symbol_to_map(const std::string& name, uintptr_t address) { diff --git a/src/main.cpp b/src/main.cpp index 9828229..4f5fb94 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2499,23 +2499,28 @@ static void read_game_pdb() } const auto h = infoStream.GetHeader(); + const std::string pdb_guid = + std::format("{:08x}-{:04x}-{:04x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + h->guid.Data1, + h->guid.Data2, + h->guid.Data3, + h->guid.Data4[0], + h->guid.Data4[1], + h->guid.Data4[2], + h->guid.Data4[3], + h->guid.Data4[4], + h->guid.Data4[5], + h->guid.Data4[6], + h->guid.Data4[7]); + // Expose the build identity so features gated on a validated game build (e.g. the native mod-settings menu) can + // disable cleanly after a game update instead of trusting stale hardcoded RVAs/offsets. + big::hades2_pdb_guid = pdb_guid; LOGF(INFO, - std::format("Version {}, signature {}, age {}, GUID " - "{:08x}-{:04x}-{:04x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + std::format("Version {}, signature {}, age {}, GUID {}", static_cast(h->version), h->signature, h->age, - h->guid.Data1, - h->guid.Data2, - h->guid.Data3, - h->guid.Data4[0], - h->guid.Data4[1], - h->guid.Data4[2], - h->guid.Data4[3], - h->guid.Data4[4], - h->guid.Data4[5], - h->guid.Data4[6], - h->guid.Data4[7])); + pdb_guid)); const PDB::DBIStream dbiStream = PDB::CreateDBIStream(rawPdbFile); From 2b6ce6fd4695d56a45874b3b371e53aaa0d215b5 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:17:44 +0100 Subject: [PATCH 055/100] Add virtual configDesc rows and a native table-based config proxy --- docs/mod_settings/README.md | 29 +- docs/mod_settings/config_schema.lua | 58 ++- src/hades2/mod_settings/config_api.cpp | 598 +++++++++++++++++++++- src/hades2/mod_settings/mod_settings.cpp | 607 ++++++++++++++++++----- src/hades2/mod_settings/mod_settings.hpp | 53 +- 5 files changed, 1207 insertions(+), 138 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index 59e440c..a7dd6a1 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -26,7 +26,10 @@ directly (see below). ## Field reference -Hover any field in the editor for its documentation. The available fields on a setting description are: +Hover any field in the editor for its documentation. The available fields on a **setting** description are +below. Two other kinds of `configDesc` entry have their own fields and sections: **action buttons** (an +`action` function - see [Action buttons](#action-buttons)) and **virtual rows** (`virtual = true` with a +`text` or `get`/`set` callback and no config value - see [Virtual rows](#virtual-rows)). | Field | Type | Purpose | | --- | --- | --- | @@ -47,6 +50,12 @@ Hover any field in the editor for its documentation. The available fields on a s | `isPercentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | | `onChange` | `fun(key, new_value)` | Called after the setting is changed in the in-game menu. Use it to apply the change to the loaded run. See below. | +## Config keys named like reserved fields + +If you happen to name a config key after one of the reserved fields above, the menu will still render them correctly, +but it is highly recommended to **not** use reserved field names as config keys to prevent confusion and potential edge +case breakage. + ## Dynamic fields (functions) Most fields can also be dynamically resolved through a function call, which is evaluated when the menu @@ -98,6 +107,24 @@ apply_scaling = { }, ``` +## Virtual rows + +A **virtual row** is a menu row that is not backed by a `config` value - its value comes from Lua callbacks. +Declare it as a `configDesc` entry whose key has no matching `config` value, marked `virtual = true`. + +A virtual row is either **read-only** or **interactive**: + +- **Read-only:** give it a `text` field - a string, or a function returning a string/number/boolean - for + the value to show. +- **Interactive:** give it `get` (reads the current value) and `set` (writes the edited value). The widget is + inferred from `get()`'s value and the metadata, exactly like a config setting is inferred from its config + value: a **boolean** is a toggle, a **number** with `min`+`max` is a slider (otherwise a number box), and any + type with a `values` list is an **enum picker**. + +Interactive rows also support `disabled`, `disabledDescription`, `editableContext`, `showAsPercentage`/ +`isPercentage`, and (for enums) `labels` - the same as config settings. `get`/`set`/`text` and the metadata +fields may be functions, re-evaluated live. + ## Reacting to changes (`onChange`) Give a setting an `onChange` function to e.g. apply its new value to the live game when the player diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 6198d23..274ea22 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -26,7 +26,6 @@ --- Upper bound for a numeric setting. Combined with `min`, the setting renders as a slider. ---@field max? mod_settings.dynamic_number --- Step between values for a slider and free-text number inputs. Defaults to 1. ---- H2M will clamp the input automatically. ---@field step? mod_settings.dynamic_number --- Enum options: the values actually stored in the .cfg file. --- Providing this makes the setting a cycler over these options. @@ -86,6 +85,61 @@ --- while the whole mod is disabled. Defaults to the normal `description` when omitted. ---@field disabledDescription? mod_settings.dynamic_string +--- A virtual row: a menu row that is NOT backed by a `config` value, whose value comes from Lua callbacks. +--- Declare it as a `configDesc` entry whose key has NO matching `config` value, with `virtual = true` (required, +--- so the menu does not warn about a missing config value). A virtual row is either: +--- - READ-ONLY: give it `text` (a string, or a function returning one). +--- - INTERACTIVE: give it `get` (read) and `set` (write). The widget is inferred from get()'s value and the +--- metadata, exactly like a config setting is inferred from its config value: a boolean is a toggle, a number +--- with `min`+`max` is a slider (else a number box), and any type with `values` is an enum picker. +--- `get`/`set`/`text` and the metadata fields (displayName/description/values/min/max/step/labels) may all be +--- functions, re-evaluated live. +---@class (exact) mod_settings.virtual_description +--- Marks this entry as a virtual row with no config backing. Required. +---@field virtual true +--- READ-ONLY value to display: a string, or a function returning a string/number/boolean (stringified). +--- Provide this OR `get`+`set` (interactive), not both. +---@field text? string | fun(): string | number | boolean +--- INTERACTIVE: reads the row's current value (boolean/number/string), which selects and seeds the widget. +--- Required for an interactive row (must be paired with `set`). +---@field get? fun(): boolean | number | string +--- INTERACTIVE: writes the edited value back. Required for an interactive row (its presence makes the row +--- interactive). For an enum row, receives the selected option as a STRING (the serialized form). +---@field set? fun(value: boolean | number | string) +--- Enum options: the values actually stored in the .cfg file. +--- Providing this makes the setting a cycler over these options. +---@field values? (string | number | boolean)[] | fun(): (string | number | boolean)[] +--- Display labels shown for each entry of `values` (same order, same number of entries). Each label may be a +--- localization table. When omitted, the raw values are shown in the cycler. +---@field labels? mod_settings.localized_string[] | fun(): mod_settings.localized_string[] +--- Lower bound for a numeric setting. Combined with `max`, the setting renders as a slider. +---@field min? mod_settings.dynamic_number +--- Upper bound for a numeric setting. Combined with `min`, the setting renders as a slider. +---@field max? mod_settings.dynamic_number +--- Step between values for a slider and free-text number inputs. Defaults to 1. +---@field step? mod_settings.dynamic_number +--- Append "%" to the displayed value. +---@field showAsPercentage? boolean +--- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). +---@field isPercentage? boolean +--- Grey the button out (non-interactive) while this is true. Updates live while the menu is open (e.g. +--- grey an "Apply" button until a value has actually changed). +---@field disabled? mod_settings.dynamic_boolean +--- Description shown in place of `description` while the setting is greyed by its own `disabled` field, to +--- explain why it is unavailable. Ignored for a context-restricted row (only editable in main menu etc.) or +--- while the whole mod is disabled. Defaults to the normal `description` when omitted. +---@field disabledDescription? mod_settings.dynamic_string +--- When the button is activated: only in the main menu, only in a save (run or Crossroads), only in the Crossroads, or anywhere. +---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" +--- Row label. Defaults to a prettified version of the config key (e.g. `myCool_Setting` -> "My Cool Setting"). +---@field displayName? mod_settings.dynamic_string +--- Help text shown at the bottom of the options menu while the config rows is highlighted. Recommended to keep +--- to about 35 characters so it leaves enough space for free-text input strings. +---@field description? mod_settings.dynamic_string +--- Sort key for custom ordering config entries in the menu, lower first. +--- When omitted, rows keep the order they are defined in the default config you provide. +---@field order? number + --- Each entry in `configDesc` can be a simple key:description string, a setting description table, an action --- button, or a nested table of descriptions mirroring a config group. The underlying .cfg file contents are --- not changed by this format. @@ -93,4 +147,4 @@ --- Only keys with a `configDesc` entry are shown in the menu: a `config` key with no entry here is treated as --- internal state and hidden (a group whose keys are all undescribed produces no row). The mod's master --- `enabled` toggle is always shown regardless, so the mod stays toggleable. ----@alias mod_settings.config_desc table +---@alias mod_settings.config_desc table diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index d45d522..68baa55 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -62,6 +62,11 @@ namespace big::mod_settings // invoked by navigation). Cleared each Lua-state init in bind_config_api. static std::map> g_actions; + // Virtual rows declared in config.lua (configDesc entries marked `virtual = true` with no backing config value). + // Keyed by guid, in config.lua source order. Like g_actions, the get/set/text callables stay in the Lua-side + // description registry and are resolved at render. Cleared per-mod in clear_metadata_for. + static std::map> g_virtual_rows; + // The config section every mod's settings are bound under (matches SGG_Modding-Chalk, keeps the .cfg // byte-compatible). Description tables in config.lua mirror the config table under this root. static constexpr const char* root_section = "config"; @@ -527,8 +532,8 @@ namespace big::mod_settings // Builds a shallow copy of a setting's description table with every dynamic (function) field replaced by its // evaluated value, so the existing extract_metadata can read it as if the author had written static values. - // `onChange` and `action` callables are intentionally left as-is (they are invoked on their own events, not read - // as metadata). + // Callables invoked on their own events (not read as metadata) are intentionally left as-is: `onChange`, `action`, + // and a virtual row's `get`/`set`/`text` (get/text are called by get_virtual_display; set takes an argument). static sol::table resolve_description(sol::state_view state, const sol::table& desc, const std::string& guid) { sol::table out = state.create_table(); @@ -540,7 +545,7 @@ namespace big::mod_settings continue; } const std::string field = k.as(); - if (field == "onChange" || field == "action") + if (field == "onChange" || field == "action" || field == "get" || field == "set" || field == "text") { out[k] = v; continue; @@ -616,7 +621,169 @@ namespace big::mod_settings } } - // Finds the config entry for (section, key), or nullptr m_entries is keyed by config_definition, so this is a + // configDesc field names that are metadata OF a setting/group/action/virtual row, not child keys. When walking a + // desc table for child rows (virtual detection + orphan validation), these are skipped so a group's OWN + // displayName/description/order/... are not mistaken for missing config keys (a group desc table mixes the group's + // metadata with its child descriptions). + static bool is_reserved_desc_field(const std::string& key) + { + static const std::set reserved = { + "displayName", + "description", + "disabledDescription", + "min", + "max", + "step", + "values", + "labels", + "order", + "hidden", + "disabled", + "freetext", + "restartRequired", + "editableContext", + "showAsPercentage", + "isPercentage", + "onChange", + "action", + "virtual", + "get", + "set", + "text", + }; + return reserved.contains(key); + } + + // Walks a mod's configDesc (guided by the config structure, like collect_actions) collecting virtual rows and + // validating every entry. A configDesc entry must resolve to one of: a config value (a config-backed setting or a + // group), an `action` function, or an explicit `virtual = true` marker. An entry that is NONE of these is almost + // always an author mistake (they described a key but forgot to add it to `config`), so it is logged. A `virtual` + // row with no `get`/`text` (nothing to display) is logged too. Recurses into config groups only, like the actions + // and defaults walks, so virtual rows live alongside config rows in a config-backed section. + static void collect_virtual_rows(const std::string& guid, const sol::table& config_tbl, const sol::object& desc_obj, const std::string& section, std::vector& out) + { + if (desc_obj.is()) + { + sol::table desc = desc_obj.as(); + for (const auto& [k, v] : desc) + { + if (k.get_type() != sol::type::string) + { + continue; + } + const std::string key = k.as(); + + // Skip the current node's own metadata fields (a group/root desc mixes them with child descriptions), + // so they are never mistaken for a child config key. + if (is_reserved_desc_field(key)) + { + continue; + } + + const std::string path = section + "." + key; + const sol::object cfg_val = config_tbl[key]; + const bool has_config = cfg_val.valid() && cfg_val.get_type() != sol::type::lua_nil; + + // A plain-string description for a key with no config value is an orphan (a described key never added + // to config). A string desc for a real config key is fine (bind_defaults handles it). + if (v.get_type() == sol::type::string) + { + if (!has_config) + { + LOG(WARNING) << "[mod_settings] " << guid << ": configDesc entry '" << path << "' has a description but no matching config value, and is not an action or a virtual row. Did you forget to add '" << key << "' to config, or mark it virtual = true?"; + } + continue; + } + if (!v.is()) + { + continue; + } + sol::table entry = v.as(); + const bool is_action = entry["action"].get_type() == sol::type::function; + const sol::object vmark = entry["virtual"]; + const bool is_virtual = vmark.is() && vmark.as(); + + if (has_config) + { + // Config-backed setting or a config group (recursed below). `virtual` here is contradictory. + if (is_virtual) + { + LOG(WARNING) << "[mod_settings] " << guid << ": configDesc entry '" << path << "' is marked virtual = true but also has a config value; treating it as a normal config setting."; + } + continue; + } + if (is_action) + { + continue; // collected by collect_actions. + } + if (!is_virtual) + { + // No config value, no action, no virtual marker: the author most likely forgot the config entry. + LOG(WARNING) << "[mod_settings] " << guid << ": configDesc entry '" << path << "' has no matching config value and is not marked `virtual = true` or given an `action`. Did you forget to add '" << key << "' to config?"; + continue; + } + + virtual_row_info vr; + vr.section = section; + vr.key = key; + if (sol::object o = entry["order"]; o.get_type() == sol::type::number) + { + vr.has_order = true; + vr.order = o.as(); + } + + const bool has_get = entry["get"].get_type() == sol::type::function; + const bool has_set = entry["set"].get_type() == sol::type::function; + const sol::object t = entry["text"]; + const bool has_text = t.get_type() == sol::type::string || t.get_type() == sol::type::function; + + // A row is interactive (an editable get/set widget) when it has a `set`, otherwise it is a read-only + // `text` row. get/set/text/values/min/max may be functions too (dynamic), so re-evaluate at render. + vr.interactive = has_set; + for (const char* field : {"displayName", "description", "text", "values", "min", "max", "step", "labels"}) + { + if (entry[field].get_type() == sol::type::function) + { + vr.has_dynamic = true; + break; + } + } + if (has_get || entry["values"].valid()) // an interactive row's value is dynamic by nature. + { + vr.has_dynamic = vr.has_dynamic || vr.interactive; + } + + if (vr.interactive) + { + // Interactive row: needs `get` to read its current value for the widget. `set` is present here. + if (!has_get) + { + LOG(WARNING) << "[mod_settings] " << guid << ": interactive virtual row '" << path << "' has a `set` but no `get`, so its widget cannot read a value; add a `get` callback."; + continue; + } + } + else if (!has_text) + { + // Read-only row: needs `text`. (A stray `get` with no `set` is not a display path.) + LOG(WARNING) << "[mod_settings] " << guid << ": virtual row '" << path << "' has no `text` (a string or a function returning one) and no `set` (to be interactive), so it has nothing to show."; + } + out.push_back(std::move(vr)); + } + } + + // Recurse into child config sections (a table config value is a group), like collect_actions. + for (const auto& [k, v] : config_tbl) + { + if (k.get_type() != sol::type::string || !v.is()) + { + continue; + } + const sol::object child_desc = desc_obj.is() ? desc_obj.as()[k] : sol::object(sol::lua_nil); + collect_virtual_rows(guid, v.as(), child_desc, section + "." + k.as(), out); + } + } + + // Finds the config entry for (section, key), or nullptr. m_entries is keyed by config_definition, so this is a // direct map lookup. static toml_v2::config_file::config_entry_base* find_entry(toml_v2::config_file* cf, const std::string& section, const std::string& key) { @@ -704,6 +871,38 @@ namespace big::mod_settings }; } + // Parses a config key that is a positive-integer array index ("1", "2", ...), used to expose array-like sections + // through #, ipairs and inext. Returns false for an empty or non-digit key. + static bool parse_positive_index(const std::string& key, long& out) + { + if (key.empty()) + { + return false; + } + long value = 0; + for (const char c : key) + { + if (c < '0' || c > '9') + { + return false; + } + value = value * 10 + (c - '0'); + } + out = value; + return value > 0; + } + + // Registry keys for the table-based config proxy machinery: one shared metatable, plus two weak-keyed maps from + // each wrapper table to the config_file and section it points at. Stored in the Lua registry so the free + // metamethods can recover them per call. + static constexpr const char* k_proxy_metatable = "h2m_mod_config_metatable"; + static constexpr const char* k_proxy_cf_map = "h2m_mod_config_cf"; + static constexpr const char* k_proxy_section_map = "h2m_mod_config_section"; + + // Builds the (empty) Lua table wrapper mods receive as their `config`, so `type(config) == "table"` (matching + // SGG_Modding-Chalk). Defined after mod_config_proxy, but the struct's child accessors call it, so forward-declare. + static sol::object make_proxy(sol::this_state ts, toml_v2::config_file* cf, const std::string& section); + // Live read/write view over a config_file section, returned to the mod as its `config` object. Reads/writes go // straight through to the underlying config entries (so the in-game menu and the mod always see the same values). // Nested sections resolve to child proxies. It holds a raw config_file pointer (not a sol reference): the @@ -723,7 +922,7 @@ namespace big::mod_settings const std::string child = section + "." + key; if (has_section(cf, child)) { - return sol::make_object(ts, mod_config_proxy{cf, child}); + return make_proxy(ts, cf, child); } return sol::lua_nil; } @@ -753,8 +952,195 @@ namespace big::mod_settings } } } + + // Snapshots this section's immediate children into a fresh Lua table: each leaf key maps to its current value + // and each direct sub-section name maps to a child proxy. The iteration metamethods hand this plain table to + // Lua's own pairs/next so consumers walk the live config exactly like a normal table (mirrors Chalk's wrapper). + sol::table children_snapshot(sol::this_state ts) const + { + sol::state_view lua(ts); + sol::table out = lua.create_table(); + const std::string prefix = section + "."; + std::set seen_children; + for (const auto& [def, entry] : cf->m_entries) + { + if (def.m_section == section) + { + out[def.m_key] = entry_get(ts, entry.get()); + } + else if (def.m_section.rfind(prefix, 0) == 0) + { + const std::string child = + def.m_section.substr(prefix.size(), def.m_section.find('.', prefix.size()) - prefix.size()); + if (seen_children.insert(child).second) + { + out[child] = make_proxy(ts, cf, prefix + child); + } + } + } + return out; + } + + // __len: highest positive-integer leaf key at this level (array length), 0 for a purely string-keyed section. + std::size_t length() const + { + std::size_t n = 0; + for (const auto& [def, entry] : cf->m_entries) + { + long index = 0; + if (def.m_section == section && parse_positive_index(def.m_key, index) && static_cast(index) > n) + { + n = static_cast(index); + } + } + return n; + } + + // __pairs: `for k, v in pairs(config)` walks one level (leaf values plus child proxies), like a plain table. + std::tuple pairs(sol::this_state ts) const + { + sol::state_view lua(ts); + sol::table snapshot = children_snapshot(ts); + sol::protected_function pairs_fn = lua["pairs"]; + sol::protected_function_result r = pairs_fn(snapshot); + return std::make_tuple(r.get(0), r.get(1), r.get(2)); + } + + // __ipairs (consulted by ipairs on Lua 5.2): iterate the 1..n integer-keyed leaves of an array-like section. + std::tuple ipairs(sol::this_state ts) const + { + sol::state_view lua(ts); + sol::table sequence = lua.create_table(); + const std::size_t n = length(); + for (std::size_t i = 1; i <= n; ++i) + { + if (auto* entry = find_entry(cf, section, std::to_string(i))) + { + sequence[i] = entry_get(ts, entry); + } + } + sol::protected_function ipairs_fn = lua["ipairs"]; + sol::protected_function_result r = ipairs_fn(sequence); + return std::make_tuple(r.get(0), r.get(1), r.get(2)); + } + + // __next (consulted by ModUtil's next/qrawpairs): step to the pair after `key` at this level. + std::tuple next(sol::this_state ts, sol::object key) const + { + sol::state_view lua(ts); + sol::table snapshot = children_snapshot(ts); + sol::protected_function next_fn = lua["next"]; + sol::protected_function_result r = next_fn(snapshot, key); + return std::make_tuple(r.get(0), r.get(1)); + } + + // __inext (consulted by ModUtil's inext/qrawipairs): step to index i + 1 of an array-like section. + std::tuple inext(sol::this_state ts, sol::object index) const + { + long i = 0; + if (index.is()) + { + i = index.as(); + } + const long next_index = i + 1; + if (auto* entry = find_entry(cf, section, std::to_string(next_index))) + { + return std::make_tuple(sol::make_object(ts, next_index), entry_get(ts, entry)); + } + return std::make_tuple(sol::object(sol::lua_nil), sol::object(sol::lua_nil)); + } }; + sol::object make_proxy(sol::this_state ts, toml_v2::config_file* cf, const std::string& section) + { + sol::state_view lua(ts); + sol::table registry = lua.registry(); + // The wrapper is an empty table: a shared metatable drives every read/write, and its (cf, section) live in the + // weak-keyed registry maps, so nothing leaks into rawpairs and the wrapper is collected with its section. + sol::table wrapper = lua.create_table(); + sol::table metatable = registry[k_proxy_metatable]; + sol::table cf_map = registry[k_proxy_cf_map]; + sol::table section_map = registry[k_proxy_section_map]; + wrapper[sol::metatable_key] = metatable; + cf_map[wrapper] = cf; + section_map[wrapper] = section; + return wrapper; + } + + // Recovers the (cf, section) a wrapper table points at, as a throwaway proxy the free metamethods delegate to. + static mod_config_proxy recover(sol::this_state ts, const sol::table& wrapper) + { + sol::state_view lua(ts); + sol::table registry = lua.registry(); + sol::table cf_map = registry[k_proxy_cf_map]; + sol::table section_map = registry[k_proxy_section_map]; + toml_v2::config_file* cf = cf_map[wrapper]; + const std::string section = section_map[wrapper]; + return mod_config_proxy{cf, section}; + } + + // Coerces a Lua index key to the string form config entries use (Chalk stringifies numeric keys). Returns false for + // a key that is neither a string nor a number. + static bool coerce_key(const sol::stack_object& key, std::string& out) + { + if (key.get_type() == sol::type::string) + { + out = key.as(); + return true; + } + if (key.get_type() == sol::type::number) + { + out = std::to_string(key.as()); + return true; + } + return false; + } + + static sol::object proxy_index(sol::this_state ts, sol::table self, sol::stack_object key) + { + std::string k; + if (!coerce_key(key, k)) + { + return sol::lua_nil; + } + return recover(ts, self).index(ts, k); + } + + static void proxy_new_index(sol::this_state ts, sol::table self, sol::stack_object key, sol::stack_object value) + { + std::string k; + if (!coerce_key(key, k)) + { + return; + } + recover(ts, self).new_index(k, value); + } + + static std::size_t proxy_length(sol::this_state ts, sol::table self) + { + return recover(ts, self).length(); + } + + static std::tuple proxy_pairs(sol::this_state ts, sol::table self) + { + return recover(ts, self).pairs(ts); + } + + static std::tuple proxy_ipairs(sol::this_state ts, sol::table self) + { + return recover(ts, self).ipairs(ts); + } + + static std::tuple proxy_next(sol::this_state ts, sol::table self, sol::object key) + { + return recover(ts, self).next(ts, key); + } + + static std::tuple proxy_inext(sol::this_state ts, sol::table self, sol::object index) + { + return recover(ts, self).inext(ts, index); + } + // A setting's extracted metadata together with the section/key it belongs to, collected while walking config.lua // and then folded into the registry. struct collected_metadata @@ -940,6 +1326,14 @@ namespace big::mod_settings collect_actions(defaults.as(), descriptions, root_section, actions); } + // Collect virtual rows (configDesc entries marked `virtual = true` with no config value) and validate that + // every configDesc entry resolves to a config value, an action, or a virtual marker (warns otherwise). + std::vector virtual_rows; + if (defaults.is()) + { + collect_virtual_rows(guid, defaults.as(), descriptions, root_section, virtual_rows); + } + // Read config.lua source to recover the author's key order (Lua pairs() and the alphabetical config map both // lose it), then rank every bound key by where it is defined. std::string source_text; @@ -958,6 +1352,12 @@ namespace big::mod_settings const std::size_t off = source_text.empty() ? std::string::npos : find_key_definition(source_text, def.m_key); by_offset.emplace_back(off, def.m_section, def.m_key); } + // Rank virtual rows in the same source-order space as the config entries so they interleave with config rows. + for (const auto& vr : virtual_rows) + { + const std::size_t off = source_text.empty() ? std::string::npos : find_key_definition(source_text, vr.key); + by_offset.emplace_back(off, vr.section, vr.key); + } std::stable_sort(by_offset.begin(), by_offset.end(), [](const auto& a, const auto& b) @@ -976,6 +1376,16 @@ namespace big::mod_settings return oa < ob; }); + // Same for virtual rows. + std::stable_sort(virtual_rows.begin(), + virtual_rows.end(), + [&](const virtual_row_info& a, const virtual_row_info& b) + { + const std::size_t oa = source_text.empty() ? std::string::npos : find_key_definition(source_text, a.key); + const std::size_t ob = source_text.empty() ? std::string::npos : find_key_definition(source_text, b.key); + return oa < ob; + }); + // Register this mod's setting metadata + appearance order (replacing any from a previous load of the same mod). { std::scoped_lock lock(g_metadata_mutex); @@ -997,10 +1407,11 @@ namespace big::mod_settings { g_appearance_order[metadata_key(guid, section, key)] = rank++; } - g_actions[guid] = std::move(actions); + g_actions[guid] = std::move(actions); + g_virtual_rows[guid] = std::move(virtual_rows); } - return sol::make_object(ts, mod_config_proxy{cf.get(), "config"}); + return make_proxy(ts, cf.get(), "config"); } std::optional resolve_setting_metadata(const std::string& guid, const std::string& section, const std::string& key) @@ -1105,6 +1516,144 @@ namespace big::mod_settings } } + std::vector get_virtual_rows(const std::string& guid, const std::string& section) + { + std::vector result; + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_virtual_rows.find(guid); + if (it == g_virtual_rows.end()) + { + return result; + } + for (const auto& vr : it->second) + { + if (vr.section == section) + { + result.push_back(vr); + } + } + return result; + } + + std::string get_virtual_display(const std::string& guid, const std::string& section, const std::string& key) + { + if (!big::g_lua_manager) + { + return {}; + } + sol::state_view state = big::g_lua_manager->lua_state(); + const sol::object root = stored_descriptions(state, guid); + const sol::object desc = navigate_description(root, section, key); + if (!desc.is()) + { + return {}; + } + sol::table t = desc.as(); + + // The read-only display comes from `text`: a plain string, or a function returning a bool/number/string that + // is stringified. Evaluated protected so a mod error cannot crash the menu. (`get`/`set` is the separate + // editable value pair, added with interactive virtual rows; it is not a display path.) + const sol::object text = t["text"]; + if (text.get_type() == sol::type::string) + { + return text.as(); + } + if (text.get_type() == sol::type::function) + { + sol::protected_function fn = text; + sol::protected_function_result rv = fn(); + if (!rv.valid()) + { + const sol::error err = rv; + LOG(WARNING) << "[mod_settings] virtual row " << section << "." << key << " for " << guid << " failed: " << err.what(); + return {}; + } + return serialize_option(rv.get()); + } + return {}; + } + + virtual_value get_virtual_value(const std::string& guid, const std::string& section, const std::string& key) + { + virtual_value out; + if (!big::g_lua_manager) + { + return out; + } + sol::state_view state = big::g_lua_manager->lua_state(); + const sol::object root = stored_descriptions(state, guid); + const sol::object desc = navigate_description(root, section, key); + if (!desc.is()) + { + return out; + } + const sol::object get = desc.as()["get"]; + if (get.get_type() != sol::type::function) + { + return out; + } + sol::protected_function fn = get; + sol::protected_function_result rv = fn(); + if (!rv.valid()) + { + const sol::error err = rv; + LOG(WARNING) << "[mod_settings] virtual row get " << section << "." << key << " for " << guid << " failed: " << err.what(); + return out; + } + const sol::object v = rv.get(); + switch (v.get_type()) + { + case sol::type::boolean: + out.type = virtual_value::kind::boolean; + out.as_bool = v.as(); + break; + case sol::type::number: + out.type = virtual_value::kind::number; + out.as_number = v.as(); + break; + case sol::type::string: + out.type = virtual_value::kind::string; + out.as_string = v.as(); + break; + default: break; // kind::none - the widget falls back to a read-only display. + } + return out; + } + + void set_virtual_value(const std::string& guid, const std::string& section, const std::string& key, const virtual_value& value) + { + if (!big::g_lua_manager) + { + return; + } + sol::state_view state = big::g_lua_manager->lua_state(); + const sol::object root = stored_descriptions(state, guid); + const sol::object desc = navigate_description(root, section, key); + if (!desc.is()) + { + return; + } + const sol::object set = desc.as()["set"]; + if (set.get_type() != sol::type::function) + { + return; + } + sol::protected_function fn = set; + sol::protected_function_result rv; + switch (value.type) + { + case virtual_value::kind::boolean: rv = fn(value.as_bool); break; + case virtual_value::kind::number: rv = fn(value.as_number); break; + case virtual_value::kind::string: rv = fn(value.as_string); break; + default: return; // nothing to write. + } + if (!rv.valid()) + { + const sol::error err = rv; + LOG(WARNING) << "[mod_settings] virtual row set " << section << "." << key << " for " << guid << " failed: " << err.what(); + } + } + // Lua API: Function. Table: mod_settings. Name: opt_out. Excludes the calling mod from the in-game mod settings // menu: it stays listed but greyed out and cannot be opened, with a note pointing the player to the mod's own // description. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. @@ -1138,11 +1687,36 @@ namespace big::mod_settings g_setting_default.clear(); g_opted_out_mods.clear(); g_actions.clear(); - } - - // Register the live-config proxy usertype once per state (mods never construct it. Instances are returned from - // load). Its index/new_index read/write the underlying config entries. - lua_ext.new_usertype("mod_config_proxy", sol::no_constructor, sol::meta_function::index, &mod_config_proxy::index, sol::meta_function::new_index, &mod_config_proxy::new_index); + g_virtual_rows.clear(); + g_described_keys.clear(); + } + + // The config object handed to mods is a plain Lua table (so `type(config) == "table"`, matching + // SGG_Modding-Chalk), driven by one shared metatable. It reproduces Chalk's full metamethod surface so mods + // migrating off Chalk keep working: index/new_index read/write entries, and len/pairs/ipairs (plus ModUtil's + // next/inext, which it reads via rawget(getmetatable(t), '__next'/'__inext')) make the config iterable like a + // normal table. Each wrapper table's (cf, section) live in weak-keyed registry maps, so the wrapper stays empty + // (nothing leaks into rawpairs) and is collected with it. + sol::table proxy_metatable = state.create_table(); + proxy_metatable["__index"] = &proxy_index; + proxy_metatable["__newindex"] = &proxy_new_index; + proxy_metatable["__len"] = &proxy_length; + proxy_metatable["__pairs"] = &proxy_pairs; + proxy_metatable["__ipairs"] = &proxy_ipairs; + proxy_metatable["__next"] = &proxy_next; + proxy_metatable["__inext"] = &proxy_inext; + + sol::table proxy_cf_map = state.create_table(); + sol::table cf_map_meta = state.create_table_with("__mode", "k"); + proxy_cf_map[sol::metatable_key] = cf_map_meta; + sol::table proxy_section_map = state.create_table(); + sol::table section_map_meta = state.create_table_with("__mode", "k"); + proxy_section_map[sol::metatable_key] = section_map_meta; + + sol::table registry = state.registry(); + registry[k_proxy_metatable] = proxy_metatable; + registry[k_proxy_cf_map] = proxy_cf_map; + registry[k_proxy_section_map] = proxy_section_map; sol::table ns = lua_ext.create_named("mod_settings"); ns.set_function("load", &load); diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index e2d3a0a..a8b4ea8 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -65,7 +65,7 @@ namespace big::mod_settings static constexpr std::size_t def_alternate_graphic = 0x88; // mAlternateGraphic (HashGuid) // SoundCue def fields (each sgg::SoundCue is 0x10 bytes: pOwner @0, mName HashGuid id @8). The base OnClicked plays // mPressSound; the native toggle handler ToggleOptionValueChanged (which our C++ toggle path replaces) is what - // plays mToggleOnSound / mToggleOffSound, so we copy the matching one into mPressSound to reproduce the sound. + // plays mToggleOnSound/mToggleOffSound, so we copy the matching one into mPressSound to reproduce the sound. static constexpr std::size_t def_press_sound = 0x1'B0; // mPressSound (sgg::SoundCue) static constexpr std::size_t def_toggle_on_sound = 0x1'E0; // mToggleOnSound (sgg::SoundCue) static constexpr std::size_t def_toggle_off_sound = 0x1'F0; // mToggleOffSound (sgg::SoundCue) @@ -85,7 +85,7 @@ namespace big::mod_settings static constexpr std::size_t def_fade_speed = 0x2'1C; // mFadeSpeed (float) opacity ease rate (component +0x2C4) // Opacity ease rate applied to every row so all row types fade at one uniform speed. The native OptionToggleButton - // / OptionNumBox templates use 10.0. CategoryOptionsButton (our text/value/ group rows) declares none, so we set it + ///OptionNumBox templates use 10.0. CategoryOptionsButton (our text/value/ group rows) declares none, so we set it // explicitly. GUIComponent::Update moves mFadeOpacity toward mFadeTarget by dt * mFadeSpeed each frame, so this // drives the fade timing. static constexpr float row_fade_speed = 10.0f; @@ -104,7 +104,7 @@ namespace big::mod_settings // The MessageDialog.sjson MessageText template renders at FontSize 26, which is larger than we want for the // multi-line body. The rendered size is driven by GUIComponentTextBox::mFontHandle (@0x6A4). Scaling its - // mFontSizeRatio (@+0x0C) / mEnglishFontSizeRatio (@+0x10) shrinks it. The def's mFontSize is ignored once the + // mFontSizeRatio (@+0x0C)/mEnglishFontSizeRatio (@+0x10) shrinks it. The def's mFontSize is ignored once the // sjson template is loaded, so we scale the live handle. static constexpr std::size_t textbox_font_handle_offset = 0x6'A4; // GUIComponentTextBox::mFontHandle static constexpr std::size_t font_handle_size_ratio_offset = 0x0C; // sgg::FontHandle::mFontSizeRatio @@ -132,7 +132,7 @@ namespace big::mod_settings // free-form cursor onto a component. Addressed by RVA off the anchor. static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; - // The config/control GLOBALS below (ConfigOptions::UseMouse / ConfigOptions::Language / Controls::Cancel / + // The config/control GLOBALS below (ConfigOptions::UseMouse/ConfigOptions::Language/Controls::Cancel/ // Controls::Select) used to be addressed by RVA off the anchor too, but they live in .data/.rdata, which a game // update can grow and shift independently of .text, so an anchor-relative RVA cannot be trusted for them. They // are named PDB globals, so they are now resolved by name (update-proof) - see set_up_hooks. @@ -199,8 +199,8 @@ namespace big::mod_settings // so keep it in sync when refreshing for a new build. static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'68; static constexpr std::size_t slider_sizeof = 0x5'B0; - static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking / mFill) - static constexpr std::size_t textbox_sizeof = 0x6'C0; // sgg::GUIComponentTextBox (mLabel / mValueTextBox) + static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking/mFill) + static constexpr std::size_t textbox_sizeof = 0x6'C0; // sgg::GUIComponentTextBox (mLabel/mValueTextBox) static constexpr std::size_t menu_screen_container_offset = 0x50; // owner + 0x50 = the IGUIComponentContainer base static constexpr std::size_t slider_parent_offset = 0x3'90; // GUIComponent::mParentContainer, SetParent writes @@ -213,23 +213,23 @@ namespace big::mod_settings static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) // GUIComponentSlider has no Draw-time highlight gate (unlike GUIComponentButton, whose Draw re-derives its - // highlight from mForceSelected / owner->mSelectedComponent). Its "moused-over" look (green label + fill) and - // "focused" look (green value) are child state set by OnMouseOver / OnFocusOn and reverted only by OnMouseOff / + // highlight from mForceSelected/owner->mSelectedComponent). Its "moused-over" look (green label + fill) and + // "focused" look (green value) are child state set by OnMouseOver/OnFocusOn and reverted only by OnMouseOff/ // OnFocusOff, so a stale flag survives across frames. mFocused is the slider's own bool the focus look tracks - // mUseSelectedTextColor is the green-text flag on a child GUIComponentTextBox (the left label / right value). + // mUseSelectedTextColor is the green-text flag on a child GUIComponentTextBox (the left label/right value). static constexpr std::size_t slider_focused_offset = 0x5'48; // GUIComponentSlider::mFocused (bool) static constexpr std::size_t textbox_use_selected_color_off = 0x5'52; // GUIComponentTextBox::mUseSelectedTextColor static constexpr std::size_t vtable_on_mouse_off_offset = 0x00'60; // GUIComponent::OnMouseOff slot static constexpr std::size_t vtable_on_unselected_offset = 0x00'88; // GUIComponent::OnUnselected slot static constexpr std::size_t vtable_on_focus_off_offset = 0x1'18; // GUIComponent::OnFocusOff slot - // Disabled-greying of a slider / num-box, which are multi-sub-component widgets: the button-style def text greying - // does not reach their separate label / value text boxes or their bar / arrow graphics, so each is greyed directly. - // A GUIComponentTextBox renders its mDisabledText colour when mUseDisabledTextColor is set (Slider / NumBox Draw + // Disabled-greying of a slider/num-box, which are multi-sub-component widgets: the button-style def text greying + // does not reach their separate label/value text boxes or their bar/arrow graphics, so each is greyed directly. + // A GUIComponentTextBox renders its mDisabledText colour when mUseDisabledTextColor is set (Slider/NumBox Draw // set it on the LABEL each frame from mIsUseable, but only if the box's def carries a non-negative disabled colour, // so we write that colour explicitly and also flag the value box, which Draw never touches). A GUIComponentImage // (slider bar) tints from mColor every frame, so writing mColor (and mColorTarget so a lerp does not undo it) dims - // it. Offsets on the text box / image component are absolute. + // it. Offsets on the text box/image component are absolute. static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor static constexpr std::size_t textbox_disabled_text_red = 0x1'E8; // mData.mDef.mDisabledTextRed (float) static constexpr std::size_t textbox_disabled_text_green = 0x1'EC; // mDisabledTextGreen (float) @@ -242,7 +242,7 @@ namespace big::mod_settings // A GUIComponentAnimation (the num-box's box/frame graphic) tints from its own mColor. NumBox::OnSelected turns the // box black by writing the selected colour here (opaque black for the OptionNumBox template); we write the same on - // a disabled num-box so its background matches the hovered look. NumBox Draw / Update never touch this field, so a + // a disabled num-box so its background matches the hovered look. NumBox Draw/Update never touch this field, so a // one-time write on a non-selectable (disabled) box sticks. static constexpr std::size_t animation_color_offset = 0x5'58; // GUIComponentAnimation::mColor (packed ARGB) static constexpr std::uint32_t numbox_hover_bg_black = 0xFF'00'00'00; // the num-box's hovered/selected box colour @@ -330,10 +330,10 @@ namespace big::mod_settings static component_focused_fn g_component_focused = nullptr; // focuses a row so it receives stick input + green static input_get_state_fn g_input_get_state = nullptr; // reads a remappable control's per-frame state static mouse_button_down_fn g_mouse_button_down = nullptr; // true while a mouse button is held (active drag detect) - static input_dir_pressed_fn g_input_was_left_pressed = nullptr; // left / decrease press edge (dpad, arrow, stick) - static input_dir_pressed_fn g_input_was_right_pressed = nullptr; // right / increase press edge - static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B / keyboard Esc) - static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A / Enter) + static input_dir_pressed_fn g_input_was_left_pressed = nullptr; // left/decrease press edge (dpad, arrow, stick) + static input_dir_pressed_fn g_input_was_right_pressed = nullptr; // right/increase press edge + static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B/keyboard Esc) + static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A/Enter) static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) static void* g_active_profile = nullptr; // &sgg::ProfileManager::ACTIVE_PROFILE @@ -395,6 +395,7 @@ namespace big::mod_settings group, // opens a nested config group (a child section) setting, // edits one config entry action, // a button that runs an action (e.g. Apply/Reset) + info, // a read-only virtual row (value from a Lua get/text callback, no config entry) }; struct PanelRow @@ -410,6 +411,8 @@ namespace big::mod_settings bool disabled = false; // greyed & non-interactable (mod disabled) bool is_enabled_toggle = false; // the mod's master "enabled" toggle + bool is_virtual_input = false; // an interactive virtual row (value via Lua get/set, not a config entry) + bool is_toggle = false; // a boolean toggle row (config bool, or an interactive virtual bool) // Author-provided description shown at the bottom of the screen while this row is highlighted (setting rows // only empty for navigation rows). @@ -512,7 +515,7 @@ namespace big::mod_settings static constexpr int keep_active_frame_count = 3; static int g_keep_active_frames = 0; - // Seconds of input quiet after a numeric setting (slider / number-box) changes before the view is rebuilt to + // Seconds of input quiet after a numeric setting (slider/number-box) changes before the view is rebuilt to // re-evaluate its dynamic (Lua-function) rows - e.g. an apply button's dynamic `disabled`. static constexpr float dynamic_refresh_settle_seconds = 0.15f; @@ -550,7 +553,7 @@ namespace big::mod_settings static bool g_edit_cancel = false; // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the author (up to the first '-') and - // runs the mod name through key_to_display, so '_' becomes a space and camelCase / PascalCase word boundaries are + // runs the mod name through key_to_display, so '_' becomes a space and camelCase/PascalCase word boundaries are // split - the same friendly-name logic used for setting keys "SGG_Modding-Chalk" -> "Chalk". // "NikkelM-Zagreus_Journey" -> "Zagreus Journey" "zerp-DreamDiveTweaks" -> "Dream Dive Tweaks". static std::string key_to_display(const std::string& key); // shared friendly-name logic, defined below @@ -871,7 +874,7 @@ namespace big::mod_settings g_set_normal_texture(row, is_on ? on_hash : off_hash, false); } - // Reproduces the vanilla toggle click sound. A native ConfigOptions toggle plays mToggleOnSound / mToggleOffSound + // Reproduces the vanilla toggle click sound. A native ConfigOptions toggle plays mToggleOnSound/mToggleOffSound // from its ValueChanged handler (MiscSettingsScreen::ToggleOptionValueChanged), which our C++ toggle path replaces, // so a toggle would otherwise be silent (the base GUIComponent::OnClicked only plays mPressSound, which the // OptionToggleButton template leaves unset). We copy the cue for the value the click will produce into mPressSound @@ -898,7 +901,7 @@ namespace big::mod_settings *reinterpret_cast(def + def_sel_text_blue) = grey; } - // Greys a child GUIComponentTextBox (a slider / num-box label or value box) by giving it a disabled text colour and + // Greys a child GUIComponentTextBox (a slider/num-box label or value box) by giving it a disabled text colour and // flagging it to use that colour. Draw greys only the LABEL (from the parent's mIsUseable) and only when the box's // def already carries a disabled colour, so we set the colour here; for the value box, which Draw never touches, // the flag persists too. Grey text colour matches set_def_text_grey so every disabled row reads the same. @@ -916,7 +919,7 @@ namespace big::mod_settings *reinterpret_cast(b + textbox_use_disabled_color_off) = true; } - // Dims a GUIComponentImage (a slider's bar backing / fill) to the disabled grey. Image::Draw tints from mColor each + // Dims a GUIComponentImage (a slider's bar backing/fill) to the disabled grey. Image::Draw tints from mColor each // frame and neither Slider::Draw nor Slider::Update recolour the bar, so writing mColor (plus mColorTarget so the // per-frame lerp does not pull it back) sticks. static void grey_image(void* image) @@ -931,17 +934,25 @@ namespace big::mod_settings } // Sets a row's normal text colour to the native settings-option grey (0.55) used by the game's own. - // OptionToggleButton / OptionNumBox rows, so plain-text (key/value) rows built on the CategoryOptionsButton + // OptionToggleButton/OptionNumBox rows, so plain-text (key/value) rows built on the CategoryOptionsButton // template (whose own text is a darker 0.35) match the toggle rows instead of reading as brighter full white. The // selected colour is left as the template's (the same green highlight both templates use) so hover still - // highlights. Must run before SetupComponent to reach the text box. - static void set_def_text_normal(GUIComponent* row) + // highlights - unless also_selected is set, which pins the selected colour to the same grey so a hovered row shows + // no highlight change (used for non-interactive info rows: normal-looking text, hoverable for its description, but + // no hover glow). Must run before SetupComponent to reach the text box. + static void set_def_text_normal(GUIComponent* row, bool also_selected = false) { char* def = reinterpret_cast(row) + component_def_offset; constexpr float option_grey = 0.55f; // matches MiscSettingsScreen.sjson option rows *reinterpret_cast(def + def_text_red) = option_grey; *reinterpret_cast(def + def_text_green) = option_grey; *reinterpret_cast(def + def_text_blue) = option_grey; + if (also_selected) + { + *reinterpret_cast(def + def_sel_text_red) = option_grey; + *reinterpret_cast(def + def_sel_text_green) = option_grey; + *reinterpret_cast(def + def_sel_text_blue) = option_grey; + } } // A plain left-justified text row (mod names, Back, and non-toggle settings). Applies a template for valid @@ -949,8 +960,12 @@ namespace big::mod_settings // left text, and a text-area hit region that hugs the label - and clears any leftover textures. Disabled rows are // greyed. By default they are also hard-disabled (non-selectable). Pass block_input=false to grey a row while // keeping it selectable, so it can still be highlighted to show its description (used for opted-out mods, whose row - // is greyed and shows a note but must not be drilled into). - static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) + // is greyed and shows a note but must not be drilled into). Pass no_hover_highlight=true for a non-interactive info + // row: it keeps normal (non-grey) text and stays mouse-hoverable (so its description shows), but the hover shows no + // green highlight (the selected text colour is pinned to the normal colour). Pair with pr.disabled to also skip + // keyboard/controller nav, giving a row the mouse can rest on to read its description but that neither cursor + // selects. + static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true, bool no_hover_highlight = false) { auto* row = create_button(screen); if (!row) @@ -981,7 +996,7 @@ namespace big::mod_settings } else { - set_def_text_normal(row); + set_def_text_normal(row, no_hover_highlight); } if (g_setup_component) @@ -1090,7 +1105,7 @@ namespace big::mod_settings // Stretch the box only enough to fit a label wider than the native box (see button_label_*), so short labels // keep the clean native box. Drawn box width = native * mScale * box_scale_x. - const float box_scale_x = std::max(1.0f, (measure_width(label) + button_label_padding) / button_label_capacity); + const float box_scale_x = std::max(1.0f, (measure_width(label) + button_label_padding)/button_label_capacity); constexpr float button_scale = 0.8f; @@ -1257,7 +1272,7 @@ namespace big::mod_settings } } - // Builds a native sgg::GUIComponentNumBox stepper row - identical to the game's own FPS-limit / graphics-quality + // Builds a native sgg::GUIComponentNumBox stepper row - identical to the game's own FPS-limit/graphics-quality // options (boxed value flanked by Arrow_Left/Arrow_Right, left/right + arrow-click stepping, keyboard + // controller). The game's factory allocates it, sets the correct vtable and builds all five sub-components (box // graphic, label, value text, both arrows), which are also freed automatically when the row vectors are torn down - @@ -1385,7 +1400,7 @@ namespace big::mod_settings } } const double scale = std::pow(10.0, decimals); - shown = std::round(shown * scale) / scale; + shown = std::round(shown * scale)/scale; std::string out = std::to_string(shown); // fixed 6-decimal form, e.g. "53.000000" if (out.find('.') != std::string::npos) @@ -1439,7 +1454,7 @@ namespace big::mod_settings std::memset(s, 0, slider_sizeof); // Base GUIComponent constructor (location passed by value. 0 = origin, overridden below by - // ApplyDataToComponent. / finalize_row), then install the slider vtable over the base one. + // ApplyDataToComponent./finalize_row), then install the slider vtable over the base one. g_gui_component_ctor(s, 0); *reinterpret_cast(s) = g_slider_vtable; @@ -1480,7 +1495,7 @@ namespace big::mod_settings // write is equivalent and avoids a vtable call. SetParent writes. GUIComponent::GUIComponent::mParentContainer. *reinterpret_cast(s + slider_parent_offset) = reinterpret_cast(screen) + menu_screen_container_offset; - // Name the slider and its value box so ApplyDataToComponent applies the OptionSlider / OptionSliderValueText + // Name the slider and its value box so ApplyDataToComponent applies the OptionSlider/OptionSliderValueText // templates (bar graphics, colours, FadeSpeed and the label styling). set_sso_string(s + gui_component_name_offset, "OptionSlider"); set_sso_string(val + gui_component_name_offset, "OptionSliderValueText"); @@ -1502,7 +1517,7 @@ namespace big::mod_settings // Paint the starting value: map [min,max] -> 0..1 and set the fraction without notifying (so the SetFraction // hook does not treat it as a user edit), then show the real value (not a percentage). const double range = max_v - min_v; - const float frac = (range > 0.0) ? static_cast((initial - min_v) / range) : 0.0f; + const float frac = (range > 0.0) ? static_cast((initial - min_v)/range) : 0.0f; g_slider_set_fraction(s, frac, false); set_slider_value_text(reinterpret_cast(s), format_setting_display(initial, show_as_pct, is_pct, step_v).c_str()); @@ -1590,7 +1605,7 @@ namespace big::mod_settings // too flags=0 destructs without the final operator delete, so we still _aligned_free the block // ourselves. void** vtbl = *reinterpret_cast(comp); - auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset / sizeof(void*)]); + auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset/sizeof(void*)]); dtor(comp, 0); } else if (g_button_dtor) @@ -1661,7 +1676,7 @@ namespace big::mod_settings } } - // Turns an identifier into a friendly display string: underscores become spaces, and camelCase / PascalCase word + // Turns an identifier into a friendly display string: underscores become spaces, and camelCase/PascalCase word // boundaries are split ("z_ThisConfigKey" -> "z. This Config Key"). An acronym run splits before its final capital // when that capital starts a lowercase word ("HTTPServer" -> "HTTP. Server"). The first letter is capitalized // ("enabled" -> "Enabled"). Used for both setting keys and mod names (via display_name_from_stem). Authors can @@ -2001,7 +2016,14 @@ namespace big::mod_settings static std::optional resolved_metadata(const std::string& stem, const std::string& section, const std::string& key) { auto meta = get_setting_metadata(stem, section, key); - if (meta && meta->has_dynamic) + if (!meta) + { + // Not a config-backed setting registered at load (e.g. a virtual row, whose metadata lives only in the Lua + // descs registry). Resolve it straight from there. Returns nullopt when there is no rich description there + // either (a Chalk setting, or a plain-string desc), exactly as before. + return resolve_setting_metadata(stem, section, key); + } + if (meta->has_dynamic) { g_view_has_dynamic = true; if (auto dynamic = resolve_setting_metadata(stem, section, key)) @@ -2061,6 +2083,108 @@ namespace big::mod_settings g_restart_required = !g_restart_changes.empty(); } + // Commit helpers for an edited row: they write the config entry (with restart-required tracking) when the row is + // config-backed, or call the interactive virtual row's Lua set() callback when it is virtual. Each returns true if + // the value actually changed, and arms the dynamic live-refresh so dependent rows re-evaluate. A virtual row is + // identified by (stem, current view section, key). + static bool commit_row_bool(PanelRow* row, bool v) + { + bool changed = false; + if (row->entry) + { + if (row->entry->get_value_base() != v) + { + capture_restart_baseline(row->entry); + row->entry->set_value_base(v); + note_change_if_restart_required(row->entry, v ? "on" : "off"); + changed = true; + } + } + else if (row->is_virtual_input) + { + const auto cur = get_virtual_value(row->stem, g_view_section, row->setting_key); + if (!(cur.type == virtual_value::kind::boolean && cur.as_bool == v)) + { + virtual_value nv; + nv.type = virtual_value::kind::boolean; + nv.as_bool = v; + set_virtual_value(row->stem, g_view_section, row->setting_key, nv); + changed = true; + } + } + if (changed && g_view_has_dynamic) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } + return changed; + } + + static bool commit_row_number(PanelRow* row, double v) + { + bool changed = false; + if (row->entry) + { + if (row->entry->get_value_base() != v) + { + capture_restart_baseline(row->entry); + row->entry->set_value_base(v); + note_change_if_restart_required(row->entry, row->entry->get_serialized_value()); + changed = true; + } + } + else if (row->is_virtual_input) + { + const auto cur = get_virtual_value(row->stem, g_view_section, row->setting_key); + if (!(cur.type == virtual_value::kind::number && cur.as_number == v)) + { + virtual_value nv; + nv.type = virtual_value::kind::number; + nv.as_number = v; + set_virtual_value(row->stem, g_view_section, row->setting_key, nv); + changed = true; + } + } + if (changed && g_view_has_dynamic) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } + return changed; + } + + // `serialized` is the config-serialized value (also the enum option's stored value). A config entry parses it back; + // a virtual set() receives it as a string (virtual enum options are matched/passed as strings). + static bool commit_row_serialized(PanelRow* row, const std::string& serialized, const std::string& display) + { + bool changed = false; + if (row->entry) + { + if (row->entry->get_serialized_value() != serialized) + { + capture_restart_baseline(row->entry); + row->entry->set_serialized_value(serialized); + note_change_if_restart_required(row->entry, display); + changed = true; + } + } + else if (row->is_virtual_input) + { + const auto cur = get_virtual_value(row->stem, g_view_section, row->setting_key); + if (!(cur.type == virtual_value::kind::string && cur.as_string == serialized)) + { + virtual_value nv; + nv.type = virtual_value::kind::string; + nv.as_string = serialized; + set_virtual_value(row->stem, g_view_section, row->setting_key, nv); + changed = true; + } + } + if (changed && g_view_has_dynamic) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } + return changed; + } + // Refreshes a freetext row's right-column value display to show `serialized`, formatted exactly as // build_mod_settings renders it (width-truncated with a leading ellipsis, then markup-escaped). Used to reflect a // committed or cancelled edit in place, without a panel rebuild. @@ -2120,7 +2244,7 @@ namespace big::mod_settings if (meta->has_step && meta->step > 0.0) { const double base = meta->has_min ? meta->min : 0.0; - v = base + std::round((v - base) / meta->step) * meta->step; + v = base + std::round((v - base)/meta->step) * meta->step; v = clamp_range(v); // snapping may overshoot a bound } g_edit_entry->set_value_base(v); @@ -2167,7 +2291,7 @@ namespace big::mod_settings { if (g_edit_component && g_set_label) { - const bool cursor_on = ((GetTickCount64() / edit_cursor_blink_ms) % 2) == 0; + const bool cursor_on = ((GetTickCount64()/edit_cursor_blink_ms) % 2) == 0; const std::string label = render_edit_display(g_edit_buffer, g_edit_cursor, cursor_on); g_set_label(g_edit_component, label.c_str()); } @@ -2182,7 +2306,7 @@ namespace big::mod_settings // True if a config entry carries an author-written description string. Chalk stores each configDesc entry's plain // description directly on the bound entry (config:bind(section, key, value, description)), so this is how a // Chalk-only mod (which never calls rom.mod_settings.load) signals that a key is described. Our own loader also - // writes the description here for string / `description`-field descs, and additionally records metadata-only descs + // writes the description here for string/`description`-field descs, and additionally records metadata-only descs // in g_described_keys, so the two checks together recognize every configDesc form as "described". static bool entry_has_description(const toml_v2::config_file::config_entry_base* entry) { @@ -2258,7 +2382,7 @@ namespace big::mod_settings { case editable_context::main_menu: return g_opened_in_game; // main-menu-only, greyed while in a save. case editable_context::in_save: return !g_opened_in_game; // in-save-only, greyed at the main menu. - case editable_context::in_hub: return !(g_opened_in_game && g_in_hub); // hub-only, greyed at menu / mid-run. + case editable_context::in_hub: return !(g_opened_in_game && g_in_hub); // hub-only, greyed at menu/mid-run. default: return false; // any. } } @@ -2294,6 +2418,19 @@ namespace big::mod_settings return note + "\n" + description; } + // True if the mod's config file `cfg` has a direct config entry at (section, key). Used so a group defers a + // group-consumed desc field (displayName/description/order/hidden) to a real config child of the same name: + // a group's desc table doubles as its children's descriptions, so such a field belongs to the child, not the group. + static bool config_child_exists(toml_v2::config_file* cfg, const std::string& section, const std::string& key) + { + if (!cfg) + { + return false; + } + toml_v2::config_definition def(section, key); + return cfg->try_get_entry(def) != nullptr; + } + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render // as setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). Each direct child // section renders as a group row that drills into it. At the root section a boolean "enabled" entry (if present) is @@ -2310,15 +2447,18 @@ namespace big::mod_settings std::string child_section; // group only (full "config.x.y" path) bool has_order = false; double order = 0.0; - int appearance = INT_MAX; // config.lua source rank (fallback order) - bool is_enabled = false; // the mod's master "enabled" toggle (root section only) - bool is_action = false; // a config.lua action button (runs a Lua callback, no config value) - action_info action; // valid when is_action + int appearance = INT_MAX; // config.lua source rank (fallback order) + bool is_enabled = false; // the mod's master "enabled" toggle (root section only) + bool is_action = false; // a config.lua action button (runs a Lua callback, no config value) + action_info action; // valid when is_action + bool is_virtual = false; // a config.lua virtual row (Lua get/text/set, no config value) + bool virtual_interactive = false; // the virtual row has a `set` (an editable get/set widget) }; std::vector items; std::map groups; // child section path -> group item (keeps its min appearance). toml_v2::config_file::config_entry_base* enabled_entry = nullptr; + toml_v2::config_file* view_cfg = nullptr; // this mod's config file (for child lookups) const std::string section_prefix = section + "."; for (auto* cfg : toml_v2::config_file::g_config_files) @@ -2327,6 +2467,7 @@ namespace big::mod_settings { continue; } + view_cfg = cfg; for (auto& [key, entry] : cfg->m_entries) { if (!entry || key.m_key == section_empty_key) @@ -2344,7 +2485,7 @@ namespace big::mod_settings if (key.m_section == section) { // Hide config keys that carry no configDesc entry, so a mod's internal or bookkeeping values do not - // clutter its settings page. A key counts as described if it has metadata / a description from our + // clutter its settings page. A key counts as described if it has metadata/a description from our // loader (g_described_keys) or a plain description string bound by Chalk (entry_has_description). // The one exception is the master "enabled" toggle, always shown so the mod stays toggleable even // when its author did not describe it. @@ -2390,7 +2531,9 @@ namespace big::mod_settings g.key = child; g.child_section = child_path; g.appearance = app; - if (const auto meta = resolved_metadata(stem, section, child); meta && meta->has_order) + // Defer the group's order to a real config child named "order" (see config_child_exists): with + // such a child, configDesc..order is that child's description, not the group's sort key. + if (const auto meta = resolved_metadata(stem, section, child); meta && meta->has_order && !config_child_exists(view_cfg, child_path, "order")) { g.has_order = true; g.order = meta->order; @@ -2423,6 +2566,25 @@ namespace big::mod_settings items.push_back(std::move(it)); } + // Virtual rows (config.lua `virtual = true` entries) - non-config rows whose value comes from Lua callbacks. + // They carry no config value either, so they are collected here and interleaved with the settings by `order` + // and config.lua source rank. A dynamic field on any of them makes an edit re-run this build (live refresh). + for (const auto& vr : get_virtual_rows(stem, section)) + { + if (vr.has_dynamic) + { + g_view_has_dynamic = true; + } + panel_item it; + it.is_virtual = true; + it.virtual_interactive = vr.interactive; + it.key = vr.key; + it.has_order = vr.has_order; + it.order = vr.order; + it.appearance = get_setting_appearance_order(stem, section, vr.key); + items.push_back(std::move(it)); + } + const bool mod_enabled = !enabled_entry || enabled_entry->get_value_base(); if (section == root_section && enabled_entry) { @@ -2535,10 +2697,223 @@ namespace big::mod_settings continue; } + // A virtual row (config.lua `virtual = true`): a menu row whose value comes from Lua callbacks, not a + // config entry. A read-only row (`text`) renders as a greyed, focusable key + value row (like a + // context-restricted setting). An interactive row (`get`/`set`) renders a real widget - toggle/enum/ + // slider/number - inferred from get()'s value and the metadata, seeded from get() and committed via + // set(). There is no `hidden`: a virtual row has no backing state, so to omit it the author does not + // declare it. + if (it.is_virtual) + { + const auto vmeta = resolved_metadata(stem, section, it.key); + const std::string vname = vmeta ? resolve_localized(vmeta->name) : std::string{}; + const std::string vlabel = escape_markup(!vname.empty() ? vname : key_to_display(it.key)); + const std::string vdesc = vmeta ? resolve_localized(vmeta->description) : std::string{}; + + // A read-only virtual (info) row, or an interactive row we cannot build a widget for (see below): a + // key + value row that just shows the display text. It looks NORMAL (not greyed - the value is + // available, only not editable) and stays mouse-hoverable so resting the pointer on it reveals its + // description (like a disabled row), but shows NO hover highlight and is skipped by keyboard/controller + // nav (pr.disabled clears mFreeFormSelectable), so neither cursor can select it. mIsUseable is left on + // (make_text_row disabled=false) so the mouse can still resolve it for the description. + const auto build_readonly = [&](const std::string& value_text) + { + if (auto* row = make_text_row(screen, vlabel.c_str(), /*disabled*/ false, /*block_input*/ false, /*no_hover_highlight*/ true)) + { + PanelRow pr{row, RowKind::info, stem, it.key}; + pr.disabled = true; + pr.value_component = make_value_display(screen, escape_markup(value_text).c_str(), /*disabled*/ false); + pr.description = vdesc; + g_rows.push_back(std::move(pr)); + } + }; + + if (!it.virtual_interactive) + { + build_readonly(get_virtual_display(stem, section, it.key)); + continue; + } + + // Interactive row. Infer the widget from get()'s value type plus the metadata, exactly like a config + // setting is inferred from its config value type. + const virtual_value vv = get_virtual_value(stem, section, it.key); + const bool is_enum = vmeta && !vmeta->values.empty(); + const bool is_bool = vv.type == virtual_value::kind::boolean; + const bool is_number = vv.type == virtual_value::kind::number; + const double step = (vmeta && vmeta->has_step) ? vmeta->step : 1.0; + const bool is_stepper = !is_enum && is_number && vmeta && vmeta->has_min && vmeta->has_max && !vmeta->freetext; + + // The current value serialized the same way config values/enum options are, for enum matching and the + // read-only fallback. + std::string vv_serialized; + switch (vv.type) + { + case virtual_value::kind::boolean: vv_serialized = vv.as_bool ? "true" : "false"; break; + case virtual_value::kind::number: vv_serialized = std::format("{}", vv.as_number); break; + case virtual_value::kind::string: vv_serialized = vv.as_string; break; + default: break; + } + + // The whole mod being disabled greys the widget (native look). Author `disabled` or an editableContext + // mismatch render the value read-only with a note, like a config setting. + const bool author_disabled = vmeta && vmeta->disabled; + const editable_context ctx = vmeta ? vmeta->context : editable_context::any; + const bool context_blocked = is_context_restricted(ctx); + + std::vector enum_values; + std::vector enum_labels; + int enum_index = 0; + if (is_enum) + { + enum_values = vmeta->values; + if (vmeta->labels.size() == enum_values.size()) + { + for (const auto& lbl : vmeta->labels) + { + enum_labels.push_back(resolve_localized(lbl)); + } + } + else + { + enum_labels = enum_values; + } + for (int i = 0; i < static_cast(enum_values.size()); ++i) + { + if (enum_values[i] == vv_serialized) + { + enum_index = i; + break; + } + } + } + + // Read-only presentation for a context/author-disabled interactive row (unless the mod is fully off, + // whose native greying covers it below). + if (!disabled && (context_blocked || author_disabled)) + { + std::string vtext; + if (is_enum && enum_index >= 0 && enum_index < static_cast(enum_labels.size())) + { + vtext = enum_labels[enum_index]; + } + else if (is_stepper) + { + vtext = format_setting_display(vv.as_number, vmeta->show_as_percentage, vmeta->is_percentage, step); + } + else + { + vtext = truncate_value(vv_serialized); + } + if (auto* ro_row = make_text_row(screen, vlabel.c_str(), /*disabled*/ true, /*block_input*/ false)) + { + PanelRow pr{ro_row, RowKind::setting, stem, it.key}; + pr.disabled = true; + pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); + pr.description = + context_blocked ? + note_then_description(context_note(ctx), vdesc) : + (!resolve_localized(vmeta->disabled_description).empty() ? resolve_localized(vmeta->disabled_description) : vdesc); + g_rows.push_back(std::move(pr)); + } + continue; + } + + GUIComponent* row = nullptr; + GUIComponent* value = nullptr; + bool built_slider = false; + if (is_enum) + { + row = make_numbox_row(screen, vlabel.c_str(), 0.0, static_cast(enum_values.size() - 1), 1.0, static_cast(enum_index), disabled, &enum_labels); + } + else if (is_bool) + { + row = make_toggle_row(screen, vlabel.c_str(), vv.as_bool, disabled); + } + else if (is_stepper) + { + row = make_slider_row(screen, + vlabel.c_str(), + vmeta->min, + vmeta->max, + step, + vv.as_number, + vmeta->show_as_percentage, + vmeta->is_percentage, + disabled); + if (row) + { + built_slider = true; + } + else + { + row = make_numbox_row(screen, vlabel.c_str(), vmeta->min, vmeta->max, step, vv.as_number, disabled); + } + } + else + { + // get() returned a string with no `values`, or nil: interactive free-text virtual rows are not + // supported yet, so show the current value read-only rather than an uneditable input. + build_readonly(truncate_value(vv_serialized)); + continue; + } + + if (row) + { + PanelRow pr{row, RowKind::setting, stem, it.key}; + pr.disabled = disabled; + pr.is_virtual_input = true; + pr.value_component = value; + pr.description = vdesc; + if (is_enum) + { + pr.is_enum = true; + pr.enum_values = std::move(enum_values); + pr.enum_labels = std::move(enum_labels); + } + else if (is_stepper) + { + pr.is_slider = built_slider; + pr.is_stepper = !built_slider; + pr.stepper_min = vmeta->min; + pr.stepper_max = vmeta->max; + pr.stepper_step = step; + pr.show_as_percentage = vmeta->show_as_percentage; + pr.is_percentage = vmeta->is_percentage; + } + else if (is_bool) + { + pr.is_toggle = true; + } + g_rows.push_back(pr); + } + continue; + } + // A nested group drills into its child section when clicked/activated. if (it.is_group) { - const auto gmeta = resolved_metadata(stem, section, it.key); + auto gmeta = resolved_metadata(stem, section, it.key); + + // A group's desc table doubles as its children's descriptions, so a group-consumed field (displayName/ + // description/hidden) that is actually one of the group's own config children belongs to that child, + // not the group. Defer to the child so the two never collide (the child renders it normally; the group + // falls back to its default for that field). `order` is deferred the same way during collection above. + if (gmeta && view_cfg) + { + if (config_child_exists(view_cfg, it.child_section, "displayName")) + { + gmeta->name.clear(); + } + if (config_child_exists(view_cfg, it.child_section, "description")) + { + gmeta->description.clear(); + } + if (config_child_exists(view_cfg, it.child_section, "hidden")) + { + gmeta->hidden = false; + } + } + if (gmeta && gmeta->hidden) { continue; @@ -2653,7 +3028,7 @@ namespace big::mod_settings if (auto* ro_row = make_text_row(screen, label.c_str(), /*disabled*/ true, /*block_input*/ false)) { PanelRow pr{ro_row, RowKind::setting, stem, key, entry}; - pr.disabled = true; // blocks every edit path (click / slider / num-box) via the row handlers + pr.disabled = true; // blocks every edit path (click/slider/num-box) via the row handlers pr.is_enabled_toggle = is_enabled_row; pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); @@ -2735,6 +3110,10 @@ namespace big::mod_settings pr.show_as_percentage = meta->show_as_percentage; pr.is_percentage = meta->is_percentage; } + else if (entry->type() == typeid(bool)) + { + pr.is_toggle = true; + } g_rows.push_back(pr); } @@ -2746,7 +3125,7 @@ namespace big::mod_settings // GUIComponent::Update (driven by MenuScreen::Update, which the original runs before this) eases mFadeOpacity // toward the target at dt * mFadeSpeed - so on-page rows are left entirely to the native ease. We only force // off-page rows fully transparent so a row leaving the page vanishes at once instead of fading out on top of the - // incoming page. Rows are in m_options / g_rows order, so row i is on the current page when start <= i < start + + // incoming page. Rows are in m_options/g_rows order, so row i is on the current page when start <= i < start + // rows_per_page. static void sync_scroll_fade(MiscSettingsScreen* screen) { @@ -2992,7 +3371,7 @@ namespace big::mod_settings auto* menu = reinterpret_cast(screen); // The native prompt strings embed a glyph token that the text box expands to the device- appropriate key icon:. - // "{CN}" = the Cancel control (Esc / B), "{SL}" = the Select/Confirm control (Enter / A). We prepend the same + // "{CN}" = the Cancel control (Esc/B), "{SL}" = the Select/Confirm control (Enter/A). We prepend the same // token to our custom labels so the icon is kept (a raw string with no token renders text only). Labels are // upper-case to match the game Cancel (Esc): "CANCEL" while editing a field "BACK" inside a mod's settings (Esc // returns to the mod list, see the ExitScreen hook) "EXIT" at the mod list (closes the options screen). @@ -3020,7 +3399,7 @@ namespace big::mod_settings case RowKind::mod_entry: confirm = "{SL} SELECT"; break; case RowKind::group: confirm = "{SL} SELECT"; break; case RowKind::setting: - if (row->entry && row->entry->type() == typeid(bool)) + if (row->is_toggle) { confirm = "{SL} TOGGLE"; } @@ -3111,7 +3490,7 @@ namespace big::mod_settings } } - // After a native page scroll (the on-screen arrow's auto-activate fires MiscSettingsScreen::ScrollDown / ScrollUp), + // After a native page scroll (the on-screen arrow's auto-activate fires MiscSettingsScreen::ScrollDown/ScrollUp), // the engine selects the new page's edge row directly - mOptions[pageStart] going down, the last on-page row going // up - via SetMouseOver plus a free-form cursor teleport, without consulting mFreeFormSelectable. So when that edge // row is disabled the cursor lands on it (a hidden highlight on a greyed row) instead of the first interactable @@ -3254,7 +3633,7 @@ namespace big::mod_settings } // Calls a no-argument GUIComponent virtual (by byte offset into the vtable) on a component. Used to invoke the - // engine's own OnMouseOff / OnFocusOff so their full revert (text-colour flag plus the fill-texture swap) runs. + // engine's own OnMouseOff/OnFocusOff so their full revert (text-colour flag plus the fill-texture swap) runs. static void call_component_vfn(GUIComponent* comp, std::size_t vtable_byte_offset) { char* vtable = *reinterpret_cast(comp); @@ -3265,8 +3644,8 @@ namespace big::mod_settings // Reverts a stale highlight left on the wrong slider or num-box row. Unlike a button, these have no Draw-time // highlight gate: their lit look is child state set by an OnXxxOn handler and undone only by the matching OnXxxOff, // never re-derived in Draw. A rebuild or our hover re-assert (which writes mMouseOverComponent directly, bypassing - // the native OnMouseOver / OnMouseOff pairing) can strand that state on a row the cursor has since left, leaving it - // stuck lit until hovered again. Each frame we revert it on any such row that is not the live mouse-over / focused + // the native OnMouseOver/OnMouseOff pairing) can strand that state on a row the cursor has since left, leaving it + // stuck lit until hovered again. Each frame we revert it on any such row that is not the live mouse-over/focused // component, so exactly the active row stays highlighted. // // Slider and num-box differ in WHICH handler sets the look: a slider's moused-over look (green label + bright fill) @@ -3299,7 +3678,7 @@ namespace big::mod_settings } } - // Focused look lives on mFocused / the value textbox. Revert it unless this row is the focused option. + // Focused look lives on mFocused/the value textbox. Revert it unless this row is the focused option. if (row.component != screen->m_component_focused && *reinterpret_cast(s + slider_focused_offset)) { call_component_vfn(row.component, vtable_on_focus_off_offset); @@ -3416,7 +3795,7 @@ namespace big::mod_settings // shrank, e.g. a row became hidden), and then to the first index of the last page - so a partial final page // (fewer than rows_per_page rows) keeps its own offset instead of being pulled up into a full page of rows. const std::uint32_t row_count = static_cast(g_rows.size()); - const std::uint32_t last_page_start = row_count > 0 ? ((row_count - 1) / rows_per_page) * rows_per_page : 0; + const std::uint32_t last_page_start = row_count > 0 ? ((row_count - 1)/rows_per_page) * rows_per_page : 0; const std::uint32_t desired = instant ? prev_start : g_pending_restore.scroll_index; start = desired > last_page_start ? last_page_start : desired; } @@ -3674,7 +4053,7 @@ namespace big::mod_settings return s; // regular spaces render in the CJK font, the entries fit without non-breaking } std::string out; - out.reserve(s.size() + s.size() / 4); + out.reserve(s.size() + s.size()/4); for (char c : s) { out += (c == ' ') ? std::string("\xC2\xA0") : std::string(1, c); @@ -3993,7 +4372,7 @@ namespace big::mod_settings } PanelRow* row = find_row(reinterpret_cast(self)); - if (!row || !row->entry) + if (!row || (!row->entry && !row->is_virtual_input)) { return; } @@ -4008,20 +4387,7 @@ namespace big::mod_settings return; } set_numbox_value_text(reinterpret_cast(self), row->enum_labels[idx].c_str()); - - const std::string& serialized = row->enum_values[idx]; - if (row->entry->get_serialized_value() != serialized) - { - capture_restart_baseline(row->entry); - row->entry->set_serialized_value(serialized); // auto-saves via on_setting_changed - note_change_if_restart_required(row->entry, row->enum_labels[idx]); - - // Re-evaluate dynamic rows once the presses settle (see g_dynamic_refresh_settle). - if (g_view_has_dynamic) - { - g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; - } - } + commit_row_serialized(row, row->enum_values[idx], row->enum_labels[idx]); return; } @@ -4031,24 +4397,11 @@ namespace big::mod_settings } const double new_value = static_cast(*reinterpret_cast(reinterpret_cast(self) + numbox_value_offset)); - if (row->entry->get_value_base() == new_value) - { - return; - } - - capture_restart_baseline(row->entry); - row->entry->set_value_base(new_value); // auto-saves via on_setting_changed - note_change_if_restart_required(row->entry, row->entry->get_serialized_value()); - - // Re-evaluate dynamic rows once the arrow presses settle (see g_dynamic_refresh_settle). - if (g_view_has_dynamic) - { - g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; - } + commit_row_number(row, new_value); } // Value-change hook for our native slider rows GUIComponentSlider::SetFraction is called with notify=true on every - // user drag / left-right adjust (the native handler also rewrites the value text to a percentage). We run the + // user drag/left-right adjust (the native handler also rewrites the value text to a percentage). We run the // original, then, for our rows, map the post-clamp fraction to the [min,max] value, snap that to the setting's step // for storage/display, and restore the real value text notify is false only for our own initial paint // (make_slider_row), so filtering on it skips that. Fires for the native audio sliders too, hence the find_row @@ -4066,7 +4419,7 @@ namespace big::mod_settings } PanelRow* row = find_row(reinterpret_cast(self)); - if (!row || !row->is_slider || !row->entry || row->disabled) + if (!row || !row->is_slider || (!row->entry && !row->is_virtual_input) || row->disabled) { return; } @@ -4081,7 +4434,7 @@ namespace big::mod_settings double v = min_v + static_cast(f) * range; if (step_v > 0.0 && range > 0.0) { - v = min_v + std::round((v - min_v) / step_v) * step_v; + v = min_v + std::round((v - min_v)/step_v) * step_v; } if (v < min_v) { @@ -4092,18 +4445,7 @@ namespace big::mod_settings v = max_v; } - if (row->entry->get_value_base() != v) - { - capture_restart_baseline(row->entry); - row->entry->set_value_base(v); // auto-saves via on_setting_changed - note_change_if_restart_required(row->entry, row->entry->get_serialized_value()); - - // Re-evaluate dynamic rows once the drag settles (see g_dynamic_refresh_settle) - if (g_view_has_dynamic) - { - g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; - } - } + commit_row_number(row, v); // Restore the real value in place of the percentage the original wrote (applying the setting's own // percentage-display options). @@ -4116,7 +4458,7 @@ namespace big::mod_settings // value text. The stored value is already on the grid, so rounding the current index is just a safety net. static void step_slider_row(void* slider, PanelRow* row, int dir) { - if (!g_slider_set_fraction || !row->entry) + if (!g_slider_set_fraction || (!row->entry && !row->is_virtual_input)) { return; } @@ -4128,7 +4470,9 @@ namespace big::mod_settings { return; } - const double idx = std::round((row->entry->get_value_base() - min_v) / step_v); + const double cur = row->entry ? row->entry->get_value_base() : + get_virtual_value(row->stem, g_view_section, row->setting_key).as_number; + const double idx = std::round((cur - min_v)/step_v); double v = min_v + (idx + dir) * step_v; if (v < min_v) { @@ -4138,7 +4482,7 @@ namespace big::mod_settings { v = max_v; } - g_slider_set_fraction(slider, static_cast((v - min_v) / range), true); + g_slider_set_fraction(slider, static_cast((v - min_v)/range), true); } // Discrete keyboard/controller stepping for our slider rows, and a disabled-row guard. The native @@ -4159,9 +4503,9 @@ namespace big::mod_settings { if (row->disabled) { - return false; // greyed slider: swallow so the native mouse-drag / slide never adjusts it + return false; // greyed slider: swallow so the native mouse-drag/slide never adjusts it } - if (row->entry && !(g_use_mouse && *g_use_mouse) && *reinterpret_cast(reinterpret_cast(self) + slider_focused_offset)) + if ((row->entry || row->is_virtual_input) && !(g_use_mouse && *g_use_mouse) && *reinterpret_cast(reinterpret_cast(self) + slider_focused_offset)) { if (g_input_was_right_pressed(input)) { @@ -4212,12 +4556,20 @@ namespace big::mod_settings // A boolean toggle flips in our own code below rather than through the native toggle handler that plays the // click sound, so stage the matching toggle cue as the press sound before the base OnClicked runs (it plays - // mPressSound). Predict the value the click produces (the flip of the current one) to pick the on / off cue. + // mPressSound). Predict the value the click produces (the flip of the current one) to pick the on/off cue. if (matched && !matched_row.disabled && matched_row.kind == RowKind::setting && matched_row.entry && matched_row.entry->type() == typeid(bool)) { stage_toggle_press_sound(self, !matched_row.entry->get_value_base()); } + else if (matched && !matched_row.disabled && matched_row.kind == RowKind::setting && matched_row.is_virtual_input) + { + const auto cur = get_virtual_value(matched_row.stem, g_view_section, matched_row.setting_key); + if (cur.type == virtual_value::kind::boolean) + { + stage_toggle_press_sound(self, !cur.as_bool); + } + } const bool result = big::g_hooking->get_original()(self, location); @@ -4291,6 +4643,19 @@ namespace big::mod_settings { enter_edit_mode(matched_row.value_component, entry); } + else if (matched_row.is_virtual_input) + { + // Interactive virtual boolean: flip through the Lua set() callback and repaint the toggle. Like the + // virtual enum/slider, dependent-row refresh is handled by the settle timer that commit_row_bool + // arms (no instant rebuild - there is no master-enable greying to apply immediately). + const auto cur = get_virtual_value(matched_row.stem, g_view_section, matched_row.setting_key); + if (cur.type == virtual_value::kind::boolean) + { + const bool new_value = !cur.as_bool; + commit_row_bool(&matched_row, new_value); + set_toggle_graphic(self, new_value); + } + } break; } case RowKind::action: @@ -4316,7 +4681,7 @@ namespace big::mod_settings // component whose eval point (location + mFreeFormSelectOffset) is close to the ray. Off-page rows are unselectable // (fade target 0), so from the last on-page row the ray finds nothing below and cannot advance. We make each arrow // the target instead by placing its eval point exactly where the next (down) or previous (up) row would be: one - // row_pitch beyond the actual last / first visible row, at that row's location. Because the offset is set relative + // row_pitch beyond the actual last/first visible row, at that row's location. Because the offset is set relative // to the arrow's own location, any shared parent offset cancels, so the eval point tracks the real row position // even after the action-button spacing shifts rows. The arrow's own auto-activate then fires ScrollDown/ScrollUp // when the nav lands on it. Off the last/first page the arrow is hidden and unselectable, so this is inert there. @@ -4476,7 +4841,7 @@ namespace big::mod_settings // native prompts alone). sync_prompts(screen, on_mods_tab); - // Revert any slider / num-box highlight left stranded on the wrong row by a rebuild or the hover re-assert. + // Revert any slider/num-box highlight left stranded on the wrong row by a rebuild or the hover re-assert. if (on_mods_tab) { clear_stale_widget_highlight(screen); @@ -4491,9 +4856,9 @@ namespace big::mod_settings // frame, so a submitting mouse click is swallowed and cannot also activate the row it lands on. Returning true // without calling the original bypasses the whole close chain (the base. MenuScreen::HandleInput is only reached // via this function's tail-call). Not editing, controller/keyboard, nothing entered yet: we drive two per-option - // behaviours the native focus delegates would (which our injected rows lack). Select (A / Enter) enters a slider or + // behaviours the native focus delegates would (which our injected rows lack). Select (A/Enter) enters a slider or // enum row so the stick then adjusts it. The native code exits it on the next. A/B And inside a mod's settings,. - // Back/CancelBack/Cancel (controller B / keyboard Esc) steps back one level - in option-navigation mode the native + // Back/CancelBack/Cancel (controller B/keyboard Esc) steps back one level - in option-navigation mode the native // Cancel handler returns the cursor to the tab bar instead of reaching our. ExitScreen back-nav, so we detect it // here (before the original) and run the back-nav ourselves. Both swallow the press. When a widget is already // entered we do nothing: native routes the stick to it and exits on. A/B. @@ -4522,7 +4887,7 @@ namespace big::mod_settings { auto* menu = reinterpret_cast(screen); - // Select enters a slider / enum row (so the stick adjusts it) toggles and buttons are left to the native + // Select enters a slider/enum row (so the stick adjusts it) toggles and buttons are left to the native // component pass. if (g_component_focused && control_pressed(input, g_controls_select)) { @@ -4545,7 +4910,7 @@ namespace big::mod_settings } // The native handler runs the keyboard/controller nav, including the on-screen scroll arrow's auto-activate at - // a page edge, which pages via ScrollDown / ScrollUp and selects the new page's edge row. Capture the page + // a page edge, which pages via ScrollDown/ScrollUp and selects the new page's edge row. Capture the page // index across the call so we can correct that landing when it falls on a disabled row (see // redirect_page_landing). Only meaningful under keyboard/controller on the Mods tab. const bool track_paging = on_mods_tab && !(g_use_mouse && *g_use_mouse); @@ -4570,7 +4935,7 @@ namespace big::mod_settings // next manual restart), so we just let the screen close normally. static void hook_MiscSettingsScreen_ExitScreen(void* self) { - // Inside a mod's settings, Esc / controller B / the on-screen Back button steps up one level: a nested group + // Inside a mod's settings, Esc/controller B/the on-screen Back button steps up one level: a nested group // returns to its parent section, and the root returns to the mod list. Only the mod-list view actually closes // the options screen. auto* screen = static_cast(self); @@ -4680,7 +5045,7 @@ namespace big::mod_settings g_disable = big::hades2_symbol_to_address["sgg::GUIComponentButton::Disable"].as_func(); // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the number-box - // stepper). The engine has no slider factory, so a slider is hand-built from the base GUIComponent / image / + // stepper). The engine has no slider factory, so a slider is hand-built from the base GUIComponent/image/ // text-box constructors and Defaults - all resolved by name here. SetFraction is both the initial set and the // drag hook (installed below). The slider vtable is resolved by name (RVA fallback) once the build is verified. g_gui_component_ctor = big::hades2_symbol_to_address["sgg::GUIComponent::GUIComponent"].as_func(); @@ -4717,7 +5082,7 @@ namespace big::mod_settings // null language skips the locale font fallback, and null Cancel/Select only degrade controller back/select // detection - none crash. UseMouse is the global bool that is false in controller/keyboard mode; Language is // the eastl string with the current display-language code; Cancel/Select are the remappable Back (controller - // B / Esc) and Select (controller A / Enter) controls whose first int indexes InputHandler's state array. + // B/Esc) and Select (controller A/Enter) controls whose first int indexes InputHandler's state array. g_use_mouse = big::hades2_symbol_to_address["sgg::ConfigOptions::UseMouse"].as(); g_config_language = big::hades2_symbol_to_address["sgg::ConfigOptions::Language"].as(); g_controls_cancel = big::hades2_symbol_to_address["sgg::Controls::Cancel"].as(); @@ -4780,7 +5145,7 @@ namespace big::mod_settings } // Build verified and every required symbol resolved: derive the remaining anchor-relative helpers and hook. - // These are all .text functions (the templated num-box factory and the overloaded MessageDialog ctor / + // These are all .text functions (the templated num-box factory and the overloaded MessageDialog ctor/ // AddScreen that cannot be picked unambiguously by name, plus TeleportCursorTo). The config/control globals // are resolved by name above (they move with .data/.rdata). const auto anchor_base = anchor.as() - anchor_rva; @@ -4810,7 +5175,7 @@ namespace big::mod_settings do_show_category); // All required by the checks above, so install unconditionally. OnClicked and SetNumberValue are global (they - // fire for every button / num-box in the game). Their callbacks filter to our rows via find_row, so installing + // fire for every button/num-box in the game). Their callbacks filter to our rows via find_row, so installing // them is a no-op for the rest of the game's UI. static auto onclick_hook = hooking::detour_hook_helper::add_queue( "sgg::GUIComponentButton::OnClicked", diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index bca04ab..29527d7 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -22,7 +22,7 @@ namespace big::mod_settings // note) when the current context does not match: any: editable anywhere (default live-read settings). main_menu: // only from the main menu (greyed while a save is loaded). Forced for a mod's master "enabled" toggle and for any // restartRequired setting. in_save: only while a save is loaded, both in the hub and mid-run (greyed at the main - // menu). in_hub: only while in the hub / Crossroads (greyed at the main menu AND mid-run), for settings unsafe to + // menu). in_hub: only while in the hub/Crossroads (greyed at the main menu AND mid-run), for settings unsafe to // change during a run. Authors declare this per setting via // `editableContext = "mainMenu" | "inSave" | "inHub" | "any"`. enum class editable_context @@ -134,10 +134,59 @@ namespace big::mod_settings // Dynamic fields are resolved against the current game state (call on the game thread). std::vector get_actions(const std::string& guid, const std::string& section); - // Runs a config.lua action button's Lua callback protected, with errors logged. No-op if the guid / section / key + // Runs a config.lua action button's Lua callback protected, with errors logged. No-op if the guid/section/key // does not resolve to an action. Call on the game thread while the Lua state is alive. void invoke_action(const std::string& guid, const std::string& section, const std::string& key); + // A configDesc entry with NO backing config value that explicitly marks itself `virtual = true`. It renders as a + // menu row whose value comes from Lua callbacks instead of a .cfg config entry: a read-only row uses `text`, and an + // interactive row uses `get` (read) + `set` (write). Collected at load; the callables stay in the Lua descs + // registry and are resolved at render. The rest of its metadata (displayName/description/order/min/max/values/...) + // is read the same way as a config setting's, via resolve_setting_metadata against (section, key). + struct virtual_row_info + { + std::string section; + std::string key; + bool has_order = false; + double order = 0.0; + bool has_dynamic = false; // a name/description/values/min/max/text field is a Lua function (re-resolve at render) + bool interactive = false; // has a `set` callback (an editable get/set row) rather than a read-only `text` row + }; + + // The virtual (non-config) rows declared directly in config `section` of mod `guid` (not recursing into child + // sections), in config.lua source order. + std::vector get_virtual_rows(const std::string& guid, const std::string& section); + + // The display string for a READ-ONLY virtual row, from its `text` (a string or a function returning one) callback. + // Call on the game thread while the Lua state is alive. Empty when the row is unavailable or has no `text`. + std::string get_virtual_display(const std::string& guid, const std::string& section, const std::string& key); + + // A virtual row's current typed value, read from its Lua `get()` callback. The kind determines which widget an + // interactive virtual row builds (like a config value's type does for a config row). + struct virtual_value + { + enum class kind + { + none, + boolean, + number, + string, + }; + kind type = kind::none; + bool as_bool = false; + double as_number = 0.0; + std::string as_string; + }; + + // Reads an interactive virtual row's current value by calling its `get()` callback (protected). Returns kind::none + // when the row has no `get`, is unavailable, or the call fails. Call on the game thread while the Lua state is + // alive. + virtual_value get_virtual_value(const std::string& guid, const std::string& section, const std::string& key); + + // Writes a new value to an interactive virtual row by calling its `set(value)` callback (protected). No-op when the + // row has no `set`. Call on the game thread while the Lua state is alive. + void set_virtual_value(const std::string& guid, const std::string& section, const std::string& key, const virtual_value& value); + // Rank of a setting's definition in its config.lua source (0 = first). Used to order rows that have no // author-declared `order` in config-file order. Returns INT_MAX for keys not bound via rom.mod_settings.load (e.g. // Chalk-bound), so they fall back to the config map order. From c88c0bab137a52ddf158be30bdd96feff91a39b6 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:14:03 +0100 Subject: [PATCH 056/100] Add type and default fields to virtual mod-settings rows --- docs/mod_settings/README.md | 20 +++ docs/mod_settings/config_schema.lua | 10 +- src/hades2/mod_settings/config_api.cpp | 152 +++++++++++++++++++++++ src/hades2/mod_settings/mod_settings.cpp | 139 +++++++++++++++------ src/hades2/mod_settings/mod_settings.hpp | 37 +++++- 5 files changed, 316 insertions(+), 42 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index a7dd6a1..9a0bb2e 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -125,6 +125,26 @@ Interactive rows also support `disabled`, `disabledDescription`, `editableContex `isPercentage`, and (for enums) `labels` - the same as config settings. `get`/`set`/`text` and the metadata fields may be functions, re-evaluated live. +Two extra fields help interactive rows that have no `.cfg` backing: + +- **`type`** - force the widget kind (`"boolean"`, `"number"`, `"string"`, or `"enum"`) when `get()` can + return `nil` at build time and so cannot be inferred. Only needed then; `"enum"` still requires `values`. +- **`default`** - the value the menu **Reset** restores the row to, applied through its `set()` callback. + Config-backed settings recover their own default automatically; a virtual row without a `default` is left + untouched by Reset. + +```lua +local preset = nil -- not chosen yet, so get() returns nil until the player picks one +local configDesc = { + preset = { + virtual = true, displayName = "Preset", + type = "enum", values = { "off", "balanced", "max" }, default = "balanced", + get = function() return preset end, + set = function(v) preset = v end, + }, +} +``` + ## Reacting to changes (`onChange`) Give a setting an `onChange` function to e.g. apply its new value to the live game when the player diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 274ea22..132eb7e 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -91,7 +91,9 @@ --- - READ-ONLY: give it `text` (a string, or a function returning one). --- - INTERACTIVE: give it `get` (read) and `set` (write). The widget is inferred from get()'s value and the --- metadata, exactly like a config setting is inferred from its config value: a boolean is a toggle, a number ---- with `min`+`max` is a slider (else a number box), and any type with `values` is an enum picker. +--- with `min`+`max` is a slider (else a number box), and any type with `values` is an enum picker. If get() +--- can return nil at build time, force the widget with `type`. Give it a `default` to have the menu Reset +--- restore it. --- `get`/`set`/`text` and the metadata fields (displayName/description/values/min/max/step/labels) may all be --- functions, re-evaluated live. ---@class (exact) mod_settings.virtual_description @@ -106,6 +108,12 @@ --- INTERACTIVE: writes the edited value back. Required for an interactive row (its presence makes the row --- interactive). For an enum row, receives the selected option as a STRING (the serialized form). ---@field set? fun(value: boolean | number | string) +--- Force the widget kind when get() may return nil at build time (so it cannot be inferred). Only needed then; +--- normally the widget is inferred from get()'s value. `"enum"` still needs `values`. +---@field type? "boolean" | "number" | "string" | "enum" +--- Value the menu Reset restores this row to, via its `set()` callback (config-backed settings recover their +--- own default instead). Rows without a `default` are left untouched by Reset. +---@field default? boolean | number | string --- Enum options: the values actually stored in the .cfg file. --- Providing this makes the setting a cycler over these options. ---@field values? (string | number | boolean)[] | fun(): (string | number | boolean)[] diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 68baa55..c779101 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -426,6 +426,37 @@ namespace big::mod_settings m.restart_required = description_requires_restart(desc); + // Virtual-row `type`: an author-forced widget kind for when get() may be nil at build time (config settings + // ignore this, their value always exists). Accepts "boolean"/"bool", "number", "string", "enum"/"enumeration". + if (sol::object type_field = desc["type"]; type_field.get_type() == sol::type::string) + { + const std::string t = type_field.as(); + if (t == "boolean" || t == "bool") + { + m.type = widget_type::boolean; + } + else if (t == "number") + { + m.type = widget_type::number; + } + else if (t == "string") + { + m.type = widget_type::string; + } + else if (t == "enum" || t == "enumeration") + { + m.type = widget_type::enumeration; + } + } + + // Virtual-row `default`: the value a menu Reset restores the row to (config settings recover their own default + // from the .cfg / config.lua). Serialized the same way as an enum option value so it round-trips through set(). + if (sol::object default_field = desc["default"]; default_field.valid() && default_field.get_type() != sol::type::lua_nil) + { + m.has_default = true; + m.default_value = serialize_option(default_field); + } + // When the setting may be changed relative to a loaded save (`editableContext`). The menu forces the master // "enabled" toggle and restartRequired settings to main_menu regardless, so authors need only annotate the // in-between cases. @@ -650,6 +681,8 @@ namespace big::mod_settings "get", "set", "text", + "type", + "default", }; return reserved.contains(key); } @@ -1654,6 +1687,125 @@ namespace big::mod_settings } } + // Parses a serialized scalar (as produced by serialize_option) back to a double, or 0.0 if it is not numeric. + static double parse_serialized_number(const std::string& s) + { + try + { + return std::stod(s); + } + catch (...) + { + return 0.0; + } + } + + // The virtual_value kind an author-forced widget_type maps to (enum options and strings are both carried as + // strings). widget_type::inferred / an unmapped value yield kind::none. + static virtual_value::kind kind_of_widget(widget_type t) + { + switch (t) + { + case widget_type::boolean: return virtual_value::kind::boolean; + case widget_type::number: return virtual_value::kind::number; + case widget_type::string: + case widget_type::enumeration: return virtual_value::kind::string; + default: return virtual_value::kind::none; + } + } + + // Builds a typed virtual_value from a serialized scalar for the given kind. kind::none yields a none value. + static virtual_value virtual_value_from_serialized(virtual_value::kind kind, const std::string& serialized) + { + virtual_value v; + v.type = kind; + switch (kind) + { + case virtual_value::kind::boolean: v.as_bool = (serialized == "true"); break; + case virtual_value::kind::number: v.as_number = parse_serialized_number(serialized); break; + case virtual_value::kind::string: v.as_string = serialized; break; + default: break; + } + return v; + } + + // Best-effort kind for a serialized default when neither get() nor an explicit `type` pins it: "true"/"false" is a + // boolean, an all-numeric parse is a number, everything else is a string. + static virtual_value::kind guess_kind_from_serialized(const std::string& s) + { + if (s == "true" || s == "false") + { + return virtual_value::kind::boolean; + } + try + { + std::size_t consumed = 0; + (void)std::stod(s, &consumed); + if (consumed == s.size()) + { + return virtual_value::kind::number; + } + } + catch (...) + { + } + return virtual_value::kind::string; + } + + bool reset_virtual_rows_to_defaults(const std::string& guid) + { + std::vector rows; + { + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_virtual_rows.find(guid); + if (it == g_virtual_rows.end()) + { + return false; + } + rows = it->second; // copy so the lock is not held across the Lua get/set callbacks below. + } + + bool any_changed = false; + for (const auto& vr : rows) + { + if (!vr.interactive) + { + continue; // read-only rows have no set() to restore through. + } + const auto meta = resolve_setting_metadata(guid, vr.section, vr.key); + if (!meta || !meta->has_default) + { + continue; // only rows that declare a `default` are reset. + } + + // Prefer the live get() kind, then an explicit `type`, then `values` (enum -> string), then a guess from + // the default's serialized form. + const virtual_value cur = get_virtual_value(guid, vr.section, vr.key); + virtual_value::kind kind = cur.type; + if (kind == virtual_value::kind::none) + { + kind = kind_of_widget(meta->type); + } + if (kind == virtual_value::kind::none) + { + kind = !meta->values.empty() ? virtual_value::kind::string : guess_kind_from_serialized(meta->default_value); + } + + const virtual_value target = virtual_value_from_serialized(kind, meta->default_value); + const bool unchanged = cur.type == target.type + && ((kind == virtual_value::kind::boolean && cur.as_bool == target.as_bool) + || (kind == virtual_value::kind::number && cur.as_number == target.as_number) + || (kind == virtual_value::kind::string && cur.as_string == target.as_string)); + if (unchanged) + { + continue; + } + set_virtual_value(guid, vr.section, vr.key, target); + any_changed = true; + } + return any_changed; + } + // Lua API: Function. Table: mod_settings. Name: opt_out. Excludes the calling mod from the in-game mod settings // menu: it stays listed but greyed out and cannot be opened, with a note pointing the player to the mod's own // description. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index a8b4ea8..d87f70f 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -199,8 +199,8 @@ namespace big::mod_settings // so keep it in sync when refreshing for a new build. static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'68; static constexpr std::size_t slider_sizeof = 0x5'B0; - static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking/mFill) - static constexpr std::size_t textbox_sizeof = 0x6'C0; // sgg::GUIComponentTextBox (mLabel/mValueTextBox) + static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking/mFill) + static constexpr std::size_t textbox_sizeof = 0x6'C0; // sgg::GUIComponentTextBox (mLabel/mValueTextBox) static constexpr std::size_t menu_screen_container_offset = 0x50; // owner + 0x50 = the IGUIComponentContainer base static constexpr std::size_t slider_parent_offset = 0x3'90; // GUIComponent::mParentContainer, SetParent writes @@ -414,6 +414,11 @@ namespace big::mod_settings bool is_virtual_input = false; // an interactive virtual row (value via Lua get/set, not a config entry) bool is_toggle = false; // a boolean toggle row (config bool, or an interactive virtual bool) + // The on/off state a virtual toggle was drawn with. A virtual toggle computes its flip from get(), but get() + // may return nil (the mod's value is not set yet, the case `type` covers) - the flip then falls back to this + // last-drawn state so the first click still works. Only meaningful for an interactive virtual bool row. + bool toggle_value = false; + // Author-provided description shown at the bottom of the screen while this row is highlighted (setting rows // only empty for navigation rows). std::string description; @@ -1105,7 +1110,7 @@ namespace big::mod_settings // Stretch the box only enough to fit a label wider than the native box (see button_label_*), so short labels // keep the clean native box. Drawn box width = native * mScale * box_scale_x. - const float box_scale_x = std::max(1.0f, (measure_width(label) + button_label_padding)/button_label_capacity); + const float box_scale_x = std::max(1.0f, (measure_width(label) + button_label_padding) / button_label_capacity); constexpr float button_scale = 0.8f; @@ -1400,7 +1405,7 @@ namespace big::mod_settings } } const double scale = std::pow(10.0, decimals); - shown = std::round(shown * scale)/scale; + shown = std::round(shown * scale) / scale; std::string out = std::to_string(shown); // fixed 6-decimal form, e.g. "53.000000" if (out.find('.') != std::string::npos) @@ -1517,7 +1522,7 @@ namespace big::mod_settings // Paint the starting value: map [min,max] -> 0..1 and set the fraction without notifying (so the SetFraction // hook does not treat it as a user edit), then show the real value (not a percentage). const double range = max_v - min_v; - const float frac = (range > 0.0) ? static_cast((initial - min_v)/range) : 0.0f; + const float frac = (range > 0.0) ? static_cast((initial - min_v) / range) : 0.0f; g_slider_set_fraction(s, frac, false); set_slider_value_text(reinterpret_cast(s), format_setting_display(initial, show_as_pct, is_pct, step_v).c_str()); @@ -1605,7 +1610,7 @@ namespace big::mod_settings // too flags=0 destructs without the final operator delete, so we still _aligned_free the block // ourselves. void** vtbl = *reinterpret_cast(comp); - auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset/sizeof(void*)]); + auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset / sizeof(void*)]); dtor(comp, 0); } else if (g_button_dtor) @@ -2244,7 +2249,7 @@ namespace big::mod_settings if (meta->has_step && meta->step > 0.0) { const double base = meta->has_min ? meta->min : 0.0; - v = base + std::round((v - base)/meta->step) * meta->step; + v = base + std::round((v - base) / meta->step) * meta->step; v = clamp_range(v); // snapping may overshoot a bound } g_edit_entry->set_value_base(v); @@ -2291,7 +2296,7 @@ namespace big::mod_settings { if (g_edit_component && g_set_label) { - const bool cursor_on = ((GetTickCount64()/edit_cursor_blink_ms) % 2) == 0; + const bool cursor_on = ((GetTickCount64() / edit_cursor_blink_ms) % 2) == 0; const std::string label = render_edit_display(g_edit_buffer, g_edit_cursor, cursor_on); g_set_label(g_edit_component, label.c_str()); } @@ -2735,12 +2740,48 @@ namespace big::mod_settings } // Interactive row. Infer the widget from get()'s value type plus the metadata, exactly like a config - // setting is inferred from its config value type. - const virtual_value vv = get_virtual_value(stem, section, it.key); - const bool is_enum = vmeta && !vmeta->values.empty(); - const bool is_bool = vv.type == virtual_value::kind::boolean; - const bool is_number = vv.type == virtual_value::kind::number; - const double step = (vmeta && vmeta->has_step) ? vmeta->step : 1.0; + // setting is inferred from its config value type. When get() returns nil at build time (the mod's state + // is not ready yet), the author can force the widget with `type` - synthesize a starting value from + // `default` (or a sensible fallback) so the widget still builds instead of falling back to read-only. + virtual_value vv = get_virtual_value(stem, section, it.key); + if (vv.type == virtual_value::kind::none && vmeta && vmeta->type != widget_type::inferred) + { + const std::string& dflt = vmeta->default_value; // empty when no default declared + switch (vmeta->type) + { + case widget_type::boolean: + vv.type = virtual_value::kind::boolean; + vv.as_bool = (dflt == "true"); + break; + case widget_type::number: + { + vv.type = virtual_value::kind::number; + double n = vmeta->has_min ? vmeta->min : 0.0; + if (vmeta->has_default) + { + try + { + n = std::stod(dflt); + } + catch (...) + { + } + } + vv.as_number = n; + break; + } + case widget_type::string: + case widget_type::enumeration: + vv.type = virtual_value::kind::string; + vv.as_string = dflt; + break; + default: break; + } + } + const bool is_enum = vmeta && !vmeta->values.empty(); + const bool is_bool = vv.type == virtual_value::kind::boolean; + const bool is_number = vv.type == virtual_value::kind::number; + const double step = (vmeta && vmeta->has_step) ? vmeta->step : 1.0; const bool is_stepper = !is_enum && is_number && vmeta && vmeta->has_min && vmeta->has_max && !vmeta->freetext; // The current value serialized the same way config values/enum options are, for enum matching and the @@ -2882,7 +2923,8 @@ namespace big::mod_settings } else if (is_bool) { - pr.is_toggle = true; + pr.is_toggle = true; + pr.toggle_value = vv.as_bool; // last-drawn state, for the flip fallback when get() is nil } g_rows.push_back(pr); } @@ -3028,7 +3070,7 @@ namespace big::mod_settings if (auto* ro_row = make_text_row(screen, label.c_str(), /*disabled*/ true, /*block_input*/ false)) { PanelRow pr{ro_row, RowKind::setting, stem, key, entry}; - pr.disabled = true; // blocks every edit path (click/slider/num-box) via the row handlers + pr.disabled = true; // blocks every edit path (click/slider/num-box) via the row handlers pr.is_enabled_toggle = is_enabled_row; pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); @@ -3795,7 +3837,7 @@ namespace big::mod_settings // shrank, e.g. a row became hidden), and then to the first index of the last page - so a partial final page // (fewer than rows_per_page rows) keeps its own offset instead of being pulled up into a full page of rows. const std::uint32_t row_count = static_cast(g_rows.size()); - const std::uint32_t last_page_start = row_count > 0 ? ((row_count - 1)/rows_per_page) * rows_per_page : 0; + const std::uint32_t last_page_start = row_count > 0 ? ((row_count - 1) / rows_per_page) * rows_per_page : 0; const std::uint32_t desired = instant ? prev_start : g_pending_restore.scroll_index; start = desired > last_page_start ? last_page_start : desired; } @@ -4004,6 +4046,13 @@ namespace big::mod_settings any_changed = true; } } + + // Interactive virtual rows are not config entries, so restore any that declare a `default` through their set() + // callback here (read-only rows and rows without a default are left untouched). + if (reset_virtual_rows_to_defaults(g_view_stem)) + { + any_changed = true; + } return any_changed; } @@ -4053,7 +4102,7 @@ namespace big::mod_settings return s; // regular spaces render in the CJK font, the entries fit without non-breaking } std::string out; - out.reserve(s.size() + s.size()/4); + out.reserve(s.size() + s.size() / 4); for (char c : s) { out += (c == ' ') ? std::string("\xC2\xA0") : std::string(1, c); @@ -4434,7 +4483,7 @@ namespace big::mod_settings double v = min_v + static_cast(f) * range; if (step_v > 0.0 && range > 0.0) { - v = min_v + std::round((v - min_v)/step_v) * step_v; + v = min_v + std::round((v - min_v) / step_v) * step_v; } if (v < min_v) { @@ -4472,7 +4521,7 @@ namespace big::mod_settings } const double cur = row->entry ? row->entry->get_value_base() : get_virtual_value(row->stem, g_view_section, row->setting_key).as_number; - const double idx = std::round((cur - min_v)/step_v); + const double idx = std::round((cur - min_v) / step_v); double v = min_v + (idx + dir) * step_v; if (v < min_v) { @@ -4482,7 +4531,7 @@ namespace big::mod_settings { v = max_v; } - g_slider_set_fraction(slider, static_cast((v - min_v)/range), true); + g_slider_set_fraction(slider, static_cast((v - min_v) / range), true); } // Discrete keyboard/controller stepping for our slider rows, and a disabled-row guard. The native @@ -4562,13 +4611,14 @@ namespace big::mod_settings { stage_toggle_press_sound(self, !matched_row.entry->get_value_base()); } - else if (matched && !matched_row.disabled && matched_row.kind == RowKind::setting && matched_row.is_virtual_input) + else if (matched && !matched_row.disabled && matched_row.kind == RowKind::setting && matched_row.is_virtual_input + && matched_row.is_toggle) { - const auto cur = get_virtual_value(matched_row.stem, g_view_section, matched_row.setting_key); - if (cur.type == virtual_value::kind::boolean) - { - stage_toggle_press_sound(self, !cur.as_bool); - } + // Predict the flipped state for the press cue. get() drives the flip, but may be nil (value not set yet), + // so fall back to the row's last-drawn state - matching the flip below. + const auto cur = get_virtual_value(matched_row.stem, g_view_section, matched_row.setting_key); + const bool cur_on = cur.type == virtual_value::kind::boolean ? cur.as_bool : matched_row.toggle_value; + stage_toggle_press_sound(self, !cur_on); } const bool result = big::g_hooking->get_original()(self, location); @@ -4643,18 +4693,19 @@ namespace big::mod_settings { enter_edit_mode(matched_row.value_component, entry); } - else if (matched_row.is_virtual_input) + else if (matched_row.is_virtual_input && matched_row.is_toggle) { // Interactive virtual boolean: flip through the Lua set() callback and repaint the toggle. Like the // virtual enum/slider, dependent-row refresh is handled by the settle timer that commit_row_bool - // arms (no instant rebuild - there is no master-enable greying to apply immediately). + // arms (no instant rebuild - there is no master-enable greying to apply immediately). get() drives + // the flip, but may be nil (the value is not set yet, the case `type` covers); fall back to the + // row's last-drawn state so the first click still toggles. After this set() runs, get() returns the + // stored value, so later clicks read it directly. const auto cur = get_virtual_value(matched_row.stem, g_view_section, matched_row.setting_key); - if (cur.type == virtual_value::kind::boolean) - { - const bool new_value = !cur.as_bool; - commit_row_bool(&matched_row, new_value); - set_toggle_graphic(self, new_value); - } + const bool cur_on = cur.type == virtual_value::kind::boolean ? cur.as_bool : matched_row.toggle_value; + const bool new_value = !cur_on; + commit_row_bool(&matched_row, new_value); + set_toggle_graphic(self, new_value); } break; } @@ -4760,7 +4811,10 @@ namespace big::mod_settings // those rows (e.g. an apply button enabling itself when a value changes). The rebuild frees and recreates the // rows, so it is deferred two ways: a short debounce absorbs the per-frame slider hook, and while the user is // still actively adjusting a row (with keyboard/controller, or an in-progress mouse drag) the rebuild is HELD - // until they finish - otherwise it would free the focused slider mid-adjust or interrupt a mouse drag. + // until they finish - otherwise it would free the focused slider mid-adjust or interrupt a mouse drag. The + // debounce also coalesces a burst of edits into a single rebuild: each commit re-arms the timer, only the final + // expiry rebuilds, build_panel clears the timer so its own rebuild cancels any still-pending one, and the + // !g_nav_pending guard folds this into a rebuild already queued by an instant path (a toggle / action). if (g_dynamic_refresh_settle > 0.0f) { if (!on_mods_tab) @@ -4783,6 +4837,19 @@ namespace big::mod_settings g_pending_stem = g_view_stem; g_pending_section = g_view_section; g_nav_pending = true; + + // Pin the row the user just edited so the rebuild keeps focus on it. Keyboard/controller focus + // is restored by build_panel's cursor tracking; in mouse mode g_keep_active_row drives the + // few-frame re-assert that steadies the prompt, description and highlight over the rebuild + // (a set() that changes a config value can otherwise blink them onto a neighbour). Mirrors the + // click/toggle path, which pins its row the same way. + if (GUIComponent* active = active_row_component(screen)) + { + if (const PanelRow* fr = find_row(active)) + { + g_keep_active_row = row_identity_of(*fr); + } + } } } } diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 29527d7..97602e5 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -33,6 +33,19 @@ namespace big::mod_settings in_hub, }; + // An author-forced widget kind for a virtual row (config.lua `type`). Virtual rows normally infer their widget + // from get()'s value type, but get() may return nil at build time (the mod's state is not ready yet), which would + // fall back to a read-only row. Declaring `type` forces the widget regardless. Ignored for config-backed settings + // (their value always exists). `enumeration` is only needed when there is no `values` list to imply it. + enum class widget_type + { + inferred, + boolean, + number, + string, + enumeration, + }; + // Author-declared metadata for a single setting, extracted from its config.lua description table by // rom.mod_settings.load. Consulted by the settings menu. Only settings whose description is a rich table have an // entry. The rest fall back to type-based rendering. Every field is an author-only input that cannot be inferred @@ -84,6 +97,14 @@ namespace big::mod_settings // is a no-op. The stored config value is never modified by either. bool show_as_percentage = false; bool is_percentage = false; + + // Virtual-row only (config.lua `type`/`default`). `type` forces the widget kind when get() cannot be relied + // on to infer it (see widget_type). `default` is the value a menu Reset restores the row to, via its set() + // callback (config settings recover their own default from the .cfg / config.lua instead), stored serialized + // like an enum option value. Both are ignored for config-backed settings. + widget_type type = widget_type::inferred; + bool has_default = false; + std::string default_value; }; // True if a mod author declared this setting as requiring a game restart to take effect (via `restart_required = @@ -147,8 +168,8 @@ namespace big::mod_settings { std::string section; std::string key; - bool has_order = false; - double order = 0.0; + bool has_order = false; + double order = 0.0; bool has_dynamic = false; // a name/description/values/min/max/text field is a Lua function (re-resolve at render) bool interactive = false; // has a `set` callback (an editable get/set row) rather than a read-only `text` row }; @@ -172,9 +193,9 @@ namespace big::mod_settings number, string, }; - kind type = kind::none; - bool as_bool = false; - double as_number = 0.0; + kind type = kind::none; + bool as_bool = false; + double as_number = 0.0; std::string as_string; }; @@ -187,6 +208,12 @@ namespace big::mod_settings // row has no `set`. Call on the game thread while the Lua state is alive. void set_virtual_value(const std::string& guid, const std::string& section, const std::string& key, const virtual_value& value); + // Restores every interactive virtual row of mod `guid` that declares a `default` to that default, via its set() + // callback. Read-only rows and rows without a `default` are left untouched. Returns true if any row's value + // actually changed. Used by the menu Reset (config-backed settings recover their own defaults separately). Call on + // the game thread while the Lua state is alive. + bool reset_virtual_rows_to_defaults(const std::string& guid); + // Rank of a setting's definition in its config.lua source (0 = first). Used to order rows that have no // author-declared `order` in config-file order. Returns INT_MAX for keys not bound via rom.mod_settings.load (e.g. // Chalk-bound), so they fall back to the config map order. From 95710fa00e5f784d7b5b56c4f7197764171e526a Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:02:24 +0100 Subject: [PATCH 057/100] Fixed typo --- docs/lua/tables/rom.mod_settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/lua/tables/rom.mod_settings.md b/docs/lua/tables/rom.mod_settings.md index 528c397..9090e46 100644 --- a/docs/lua/tables/rom.mod_settings.md +++ b/docs/lua/tables/rom.mod_settings.md @@ -20,7 +20,7 @@ table = rom.mod_settings.load(config_lua) ### `opt_out()` -Excludes the calling mod from the in-game mod settings menu: it stays listed but will begreyed out and +Excludes the calling mod from the in-game mod settings menu: it stays listed but will be greyed out and cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. From 3be22f8dca9279da2e053a2ae881fd8930e7e72e Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:42:16 +0100 Subject: [PATCH 058/100] Fixed disabled action buttons being clickable --- src/hades2/mod_settings/mod_settings.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index d87f70f..fa5359b 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -4621,6 +4621,15 @@ namespace big::mod_settings stage_toggle_press_sound(self, !cur_on); } + // A matched but disabled row (a greyed action button or a context-restricted setting that stays selectable so + // its note still shows on hover) must not react to a click. The base GUIComponent::OnClicked plays mPressSound + // and swaps the button's pressed graphic even though the row has no usable activate, so calling it would sound + // and visually "press" a control the user cannot use. Skip the base call and consume the click as a no-op. + if (matched && matched_row.disabled) + { + return false; + } + const bool result = big::g_hooking->get_original()(self, location); if (matched && !matched_row.disabled) From 1a0a2843f354df1f9e16335545e80babdbea9277 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:51:49 +0100 Subject: [PATCH 059/100] Suppress press feedback on greyed mod-settings action buttons and bound interactive slider hit rects so hover and nav work on mixed pages --- src/hades2/mod_settings/mod_settings.cpp | 41 ++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index fa5359b..cf6da0f 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -322,6 +322,12 @@ namespace big::mod_settings static slider_defaults_fn g_slider_defaults = nullptr; static slider_set_fraction_fn g_slider_set_fraction = nullptr; static std::uintptr_t g_slider_vtable = 0; // runtime + // A patched copy of the slider vtable (built in set_up_hooks) whose GetArea/GetScreenArea slots return a one-row + // hit rect (see slider_bounded_area), replacing the native ones that union the slider's sub-components into a + // screen-spanning rect. 128 slots comfortably covers the class's virtual table. + static constexpr std::size_t slider_vtable_slot_count = 128; + static std::uintptr_t g_slider_vtable_copy[slider_vtable_slot_count] = {}; + static std::uintptr_t g_slider_vtable_patched = 0; // runtime static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) static set_mouse_over_fn g_set_mouse_over = nullptr; // MenuScreen::SetMouseOver (highlight + select a row) static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) @@ -1438,6 +1444,23 @@ namespace big::mod_settings // bounded numeric setting. The slider stores a normalized 0..1 fraction. We map the setting's [min,max] onto it and // snap drags to `step` in the SetFraction hook. The engine has no factory for this type, so this replicates the // construction DoShowCategory performs for the volume rows: allocate the block, run the base GUIComponent + // Row-sized hover/nav hit rect for our interactive slider rows, installed via the patched slider vtable + // (GetArea vtable +0x98 and GetScreenArea +0xA0). The native GUIComponentSlider::GetArea unions the slider's + // sub-components (bar, fill, label, value) into a rectangle spanning most of the screen; via the nearest-anchor + // tiebreak in MenuScreen::UpdateMouseOver that giant rect steals mouse hover from every other row on a page mixing + // sliders with other row types (and the polluted mouse-over then overrides keyboard nav). Returning a one-row rect + // (matching the toggle/text rows' footprint) makes the slider hit-test like any other row. Dragging is unaffected: + // it runs through GUIComponentSlider::HandleInput (hooked separately), not GetArea. IRectangle is {x,y,w,h} int32. + static void* slider_bounded_area(GUIComponent* self, std::int32_t* out) + { + const int left = static_cast(row_location_x + row_text_offset_x); // option-name column start (~660) + out[0] = left; + out[1] = static_cast(self->m_location_y) - 22; + out[2] = static_cast(row_location_x) + 22 - left; // out to the value column (~922 wide) + out[3] = 44; // one row tall, under row_pitch so no overlap + return out; + } + // constructor, install the slider vtable, zero the fields Defaults leaves untouched, then run Defaults and allocate // the four owned sub-components (bar background, fill, label, value text). Named "OptionSlider" so // ApplyDataToComponent applies the matching sjson template (bar graphics, colours, FadeSpeed, label styling). @@ -1459,9 +1482,10 @@ namespace big::mod_settings std::memset(s, 0, slider_sizeof); // Base GUIComponent constructor (location passed by value. 0 = origin, overridden below by - // ApplyDataToComponent./finalize_row), then install the slider vtable over the base one. + // ApplyDataToComponent./finalize_row), then install the patched slider vtable (bounded GetArea) over the base + // one, falling back to the unpatched native vtable if the copy was not built. g_gui_component_ctor(s, 0); - *reinterpret_cast(s) = g_slider_vtable; + *reinterpret_cast(s) = g_slider_vtable_patched ? g_slider_vtable_patched : g_slider_vtable; // Defaults does not initialise mOnValueChanged or mValueTextBox, so zero them (the block is freshly malloc'd) // before Defaults runs and before anything reads them. @@ -5241,6 +5265,19 @@ namespace big::mod_settings g_slider_vtable = anchor_base + slider_vtable_rva; } + // Build a patched copy of the slider vtable whose GetArea (+0x98) and GetScreenArea (+0xA0) slots return a + // one-row hit rect. The native slider GetArea unions the slider's sub-components into a screen-spanning + // rectangle that, through the nearest-anchor hover tiebreak, hijacks mouse hover (and keyboard nav) from other + // rows on a mixed page. Copying the whole table keeps every other virtual (ctor/dtor/Draw/HandleInput/...) + // intact; only the two area getters are redirected. + if (g_slider_vtable) + { + std::memcpy(g_slider_vtable_copy, reinterpret_cast(g_slider_vtable), sizeof(g_slider_vtable_copy)); + g_slider_vtable_copy[0x98 / sizeof(std::uintptr_t)] = reinterpret_cast(&slider_bounded_area); + g_slider_vtable_copy[0xA0 / sizeof(std::uintptr_t)] = reinterpret_cast(&slider_bounded_area); + g_slider_vtable_patched = reinterpret_cast(g_slider_vtable_copy); + } + g_feature_enabled = true; static auto ctor_hook = hooking::detour_hook_helper::add_queue( From 2a8f3c31505aea247fef37085621b2bf364f1c70 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:37:42 +0100 Subject: [PATCH 060/100] Fix disabled widget rendering --- src/hades2/mod_settings/mod_settings.cpp | 295 ++++++++++++++++------- 1 file changed, 203 insertions(+), 92 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index cf6da0f..413a630 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -231,14 +231,31 @@ namespace big::mod_settings // (slider bar) tints from mColor every frame, so writing mColor (and mColorTarget so a lerp does not undo it) dims // it. Offsets on the text box/image component are absolute. static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor + // Normal (0x1B4) and selected (0x1D0) text colours on the child text box, from the component def + // (component_def_offset 0xA8 + def_text_red 0x10C/def_sel_text_red 0x128). A Slider/NumBox Draw sets + // mUseDisabledTextColor from mIsUseable each frame, so a still-selectable (mIsUseable=1) greyed row would ignore + // the disabled colour and paint the bright normal (and, on hover, the selected) colour instead. Greying these two + // as well keeps the label grey in every state - matches how set_def_text_grey greys a button row's own def. + static constexpr std::size_t textbox_text_red = 0x1'B4; // mData.mDef.mTextRed (float) + static constexpr std::size_t textbox_selected_text_red = 0x1'D0; // mData.mDef.mSelectedTextRed (float) static constexpr std::size_t textbox_disabled_text_red = 0x1'E8; // mData.mDef.mDisabledTextRed (float) static constexpr std::size_t textbox_disabled_text_green = 0x1'EC; // mDisabledTextGreen (float) static constexpr std::size_t textbox_disabled_text_blue = 0x1'F0; // mDisabledTextBlue (float) static constexpr std::size_t textbox_disabled_text_alpha = 0x1'F4; // mDisabledTextAlpha (float) static constexpr std::size_t image_color_offset = 0x5'44; // GUIComponentImage::mColor (packed RGBA) static constexpr std::size_t image_color_target_offset = 0x00'78; // mColorTarget (packed RGBA) + static constexpr std::size_t button_graphic_color_offset = 0x5'5C; // GUIComponentButton::mButtonColor - the colour Draw paints the toggle graphic with + static constexpr std::size_t component_color_target_offset = 0x00'78; // GUIComponent::mColorTarget (Update eases mButtonColor toward this) + static constexpr std::size_t def_sel_red = 0xFC; // ComponentDataDef::mSelectedRed; set <0 to disable the selected-colour override in Draw/On(Un)Selected static constexpr float disabled_text_grey = 0.22f; // matches set_def_text_grey (toggle/text rows) static constexpr std::uint32_t disabled_graphic_grey = 0xFF'66'66'66; // opaque 0.4 grey (packed A,B,G,R) + // GUIComponentTextBox::SetTextColor is vtable slot +0x160; it writes the cached runtime mTextColor (which the text + // box Draw renders when neither the disabled nor selected colour flag is set). The template caches a bright colour + // at build, and greying the def alone does not update it, so a still-selectable greyed widget label stays bright - + // we re-apply this grey through SetTextColor instead (the same call MiscSettingsScreen::UpdateButtonStates uses to + // grey a still-hoverable option). Packed the same A,B,G,R way as disabled_graphic_grey (opaque 0.22 grey). + static constexpr std::size_t vtable_set_text_color_offset = 0x1'60; + static constexpr std::uint32_t disabled_label_grey_packed = 0xFF'38'38'38; // A GUIComponentAnimation (the num-box's box/frame graphic) tints from its own mColor. NumBox::OnSelected turns the // box black by writing the selected colour here (opaque black for the OptionNumBox template); we write the same on @@ -372,12 +389,6 @@ namespace big::mod_settings static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) static constexpr std::uint32_t rows_per_page = 10; // vanilla ItemsPerPage = 10 - // Action-button rows use the taller Button_Secondary box, which overflows the uniform row pitch. After the native - // layout, sync_button_spacing nudges each button down by this lead and shifts the rows below it by lead+trail, so - // buttons get vertical breathing room without overlapping. - static constexpr float button_extra_lead = 14.0f; - static constexpr float button_extra_trail = 14.0f; - // Config sections. Both rom.mod_settings.load and Chalk bind a mod's settings under the root "config" section. // Nested groups are dot-separated child sections (e.g. "config.biome_pool"). static const std::string root_section = "config"; @@ -928,6 +939,17 @@ namespace big::mod_settings *reinterpret_cast(b + textbox_disabled_text_blue) = disabled_text_grey; *reinterpret_cast(b + textbox_disabled_text_alpha) = 1.0f; *reinterpret_cast(b + textbox_use_disabled_color_off) = true; + + // Also grey the normal and selected text colours (red/green/blue triples). A still-selectable greyed row keeps + // mIsUseable=1, so Slider/NumBox Draw clears mUseDisabledTextColor and the label falls back to the normal + // colour (and the selected colour on hover) - greying both keeps it greyed in every state, with no hover + // highlight. The disabled-only path (mIsUseable=0) is unaffected since these just match the disabled grey. + for (const std::size_t base : {textbox_text_red, textbox_selected_text_red}) + { + *reinterpret_cast(b + base + 0x0) = disabled_text_grey; + *reinterpret_cast(b + base + 0x4) = disabled_text_grey; + *reinterpret_cast(b + base + 0x8) = disabled_text_grey; + } } // Dims a GUIComponentImage (a slider's bar backing/fill) to the disabled grey. Image::Draw tints from mColor each @@ -944,6 +966,21 @@ namespace big::mod_settings *reinterpret_cast(b + image_color_target_offset) = disabled_graphic_grey; } + // Greys a disabled toggle's on/off ring so it reads greyed from frame one. The ring (mNormalTexture) is a bare + // texture id with no colour of its own - GUIComponentButton::Draw paints it with mButtonColor@0x55C, which starts + // black and the engine only eases to the greyed mColorTarget@0x78 in Update/on selection, so an untouched disabled + // toggle shows black until a hover eases it grey. We set the live paint colour AND the ease target to the disabled + // grey (so Update sees them equal and never eases away), and set the def's mSelectedRed < 0 so Draw/On(Un)Selected + // skip the selected-colour override - the ring then reads greyed at rest and stays greyed through hover/selection. + // All are fixed-offset writes on the button itself (no child-pointer dereference), unlike the slider's owned images. + static void grey_toggle_graphic(GUIComponent* row) + { + char* b = reinterpret_cast(row); + *reinterpret_cast(b + button_graphic_color_offset) = disabled_graphic_grey; + *reinterpret_cast(b + component_color_target_offset) = disabled_graphic_grey; + *reinterpret_cast(b + component_def_offset + def_sel_red) = -1.0f; + } + // Sets a row's normal text colour to the native settings-option grey (0.55) used by the game's own. // OptionToggleButton/OptionNumBox rows, so plain-text (key/value) rows built on the CategoryOptionsButton // template (whose own text is a darker 0.35) match the toggle rows instead of reading as brighter full white. The @@ -1047,8 +1084,10 @@ namespace big::mod_settings // A toggle row (boolean setting): a left-justified label plus the native on/off toggle switch graphic on the right. // The OptionToggleButton template already supplies the toggle graphic, left-justified text and text area we only // realign it to our row grid (mY/mSpacing, read directly by UpdateScrollState) and choose the on/off graphic. - // Disabled rows are greyed and made non-interactable. - static GUIComponent* make_toggle_row(MiscSettingsScreen* screen, const char* label, bool is_on, bool disabled = false) + // Disabled rows grey their ring (grey_toggle_graphic) and label from frame one. By default (block_input=true, the + // whole-mod-off case) they also drop mIsUseable via Disable so keyboard nav and mouse hover skip the row. Pass + // block_input=false to keep the row mouse-hoverable for its note (context/author-disabled), still greyed. + static GUIComponent* make_toggle_row(MiscSettingsScreen* screen, const char* label, bool is_on, bool disabled = false, bool block_input = true) { auto* row = create_button(screen); if (!row) @@ -1087,9 +1126,20 @@ namespace big::mod_settings set_toggle_graphic(row, is_on); - if (disabled && g_disable) + if (disabled) { - g_disable(row); + // Grey the on/off ring from frame one (it has no colour of its own and would otherwise stay black until a + // hover eases it grey - see grey_toggle_graphic). + grey_toggle_graphic(reinterpret_cast(row)); + + if (block_input && g_disable) + { + // Whole-mod-off toggle: also drop mIsUseable (via Disable) so keyboard nav and mouse hover skip the row + // - the same path the menu has always used for a disabled option (edits are already blocked by + // pr.disabled). A context/author-disabled toggle (block_input=false) keeps mIsUseable so it stays + // mouse-hoverable for its note. + g_disable(row); + } } finalize_row(screen, row); @@ -1291,7 +1341,7 @@ namespace big::mod_settings // Returns the num-box component (not a GUIComponentButton, so it never routes through the OnClicked hook). When // `value_labels` is non-null. The box is an enum cycler: it steps the integer index and its value text is // overridden to the matching label instead of the raw number. - static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled, const std::vector* value_labels = nullptr) + static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled, const std::vector* value_labels = nullptr, bool block_input = true) { if (!g_numbox_factory || !g_numbox_set_range || !g_numbox_set_value || !g_apply_data || !g_show_text) { @@ -1370,18 +1420,22 @@ namespace big::mod_settings if (disabled) { // mDisableInput is the num-box's own input gate (its HandleInput early-outs on it), blocking both the - // arrow-clicks and keyboard/controller stepping - mIsUseable does NOT gate num-box input. Also clear - // mIsUseable so it is non-selectable (nav/hover skip it and NumBox::Draw greys the label from mIsUseable) - // and grey the value box. The arrows are left visible (just inert, since mDisableInput blocks their click). - // The box graphic is set to the hovered/selected black so a disabled enum reads with the same black - // background it shows on hover (the box is non-selectable, so nothing reverts this write). + // arrow-clicks and keyboard/controller stepping - mIsUseable does NOT gate num-box input, so it is always + // set on a disabled box. Grey the label and value boxes (grey_text_box also greys their normal/selected + // colours so a still-selectable box stays greyed and does not highlight on hover). When block_input is set + // (the whole-mod-off case) also clear mIsUseable so nav/hover skip it and force the box to the hovered + // black so it reads consistently; a still-selectable (block_input=false) context/author-disabled box keeps + // mIsUseable so it stays hoverable for its note and leaves the box graphic at its greyed default. *reinterpret_cast(nb_bytes + numbox_disable_input_offset) = true; - nb->m_is_useable = false; grey_text_box(*reinterpret_cast(nb_bytes + numbox_label_text_offset)); grey_text_box(*reinterpret_cast(nb_bytes + numbox_value_text_offset)); - if (auto* box = *reinterpret_cast(nb_bytes + numbox_anim_offset)) + if (block_input) { - *reinterpret_cast(box + animation_color_offset) = numbox_hover_bg_black; + nb->m_is_useable = false; + if (auto* box = *reinterpret_cast(nb_bytes + numbox_anim_offset)) + { + *reinterpret_cast(box + animation_color_offset) = numbox_hover_bg_black; + } } } @@ -1467,7 +1521,7 @@ namespace big::mod_settings // Teardown mirrors the num-box: destroy_rows routes it through the vtable deleting destructor (which frees the // sub-components) then _aligned_free. Returns null if any required engine helper is missing, in which case the // caller falls back to a number-box stepper. - static GUIComponent* make_slider_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool show_as_pct, bool is_pct, bool disabled) + static GUIComponent* make_slider_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool show_as_pct, bool is_pct, bool disabled, bool block_input = true) { if (!g_gui_component_ctor || !g_image_ctor || !g_textbox_ctor || !g_slider_defaults || !g_slider_set_fraction || !g_slider_vtable || !g_apply_data || !g_show_text) { @@ -1553,12 +1607,17 @@ namespace big::mod_settings if (disabled) { - // Non-interactive (mIsUseable=0 makes it non-selectable, so nav/hover skip it and Slider::Draw greys the - // label from mIsUseable), plus explicit greying of the parts Draw leaves bright: the value box and both bar - // images. Mouse-drag is separately blocked in the HandleInput hook (the native drag path ignores + // Grey every visible part explicitly: the value box and both bar images, plus the label (which Slider::Draw + // would otherwise only grey off mIsUseable). block_input=true also clears mIsUseable so nav/hover skip the + // row (the whole-mod-off case). block_input=false keeps it selectable so a context-restricted or + // author-disabled slider stays greyed-but-visible and hoverable to show its note, with edits blocked by + // pr.disabled. Mouse-drag is separately blocked in the HandleInput hook (the native drag path ignores // mIsUseable). - auto* sc = reinterpret_cast(s); - sc->m_is_useable = false; + auto* sc = reinterpret_cast(s); + if (block_input) + { + sc->m_is_useable = false; + } grey_text_box(*reinterpret_cast(s + slider_label_offset)); grey_text_box(*reinterpret_cast(s + slider_value_text_offset)); grey_image(*reinterpret_cast(s + slider_backing_offset)); @@ -3073,30 +3132,76 @@ namespace big::mod_settings const bool context_blocked = is_context_restricted(ctx); if (!disabled && (context_blocked || author_disabled)) { - std::string vtext; + // Greyed but still visible: the setting keeps its real widget (toggle/enum cycler/slider), greyed + // and focusable so the description box can explain why it is unavailable, with edits blocked by + // pr.disabled in the row handlers. Only a plain string falls back to a greyed key + value text row. + // block_input=false keeps each widget selectable (hoverable for its note) while still greyed. + GUIComponent* ro_row = nullptr; + GUIComponent* ro_value = nullptr; + bool ro_is_toggle = false; + bool ro_is_enum = false; // real enum cycler (carries values/labels) + bool ro_is_numbox = false; // numeric num-box (stepper fallback when the slider cannot be built) + bool ro_is_slider = false; if (entry->type() == typeid(bool)) { - vtext = entry->get_value_base() ? "true" : "false"; + ro_row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), /*disabled*/ true, /*block_input*/ false); + ro_is_toggle = ro_row != nullptr; } - else if (is_enum && enum_index >= 0 && enum_index < static_cast(enum_labels.size())) + else if (is_enum) { - vtext = enum_labels[enum_index]; + ro_row = make_numbox_row(screen, label.c_str(), 0.0, static_cast(enum_values.size() - 1), 1.0, static_cast(enum_index), /*disabled*/ true, &enum_labels, /*block_input*/ false); + ro_is_enum = ro_row != nullptr; } else if (is_stepper) { - vtext = format_setting_display(entry->get_value_base(), meta->show_as_percentage, meta->is_percentage, step); + ro_row = make_slider_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), meta->show_as_percentage, meta->is_percentage, /*disabled*/ true, /*block_input*/ false); + ro_is_slider = ro_row != nullptr; + if (!ro_row) + { + ro_row = make_numbox_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), /*disabled*/ true, nullptr, /*block_input*/ false); + ro_is_numbox = ro_row != nullptr; + } } - else + if (!ro_row) { - vtext = truncate_value(entry->get_serialized_value()); + // Plain string (or a widget that could not be built): greyed key + value text row. + const std::string vtext = truncate_value(entry->get_serialized_value()); + ro_row = make_text_row(screen, label.c_str(), /*disabled*/ true, /*block_input*/ false); + if (ro_row) + { + ro_value = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); + } } - if (auto* ro_row = make_text_row(screen, label.c_str(), /*disabled*/ true, /*block_input*/ false)) + if (ro_row) { PanelRow pr{ro_row, RowKind::setting, stem, key, entry}; pr.disabled = true; // blocks every edit path (click/slider/num-box) via the row handlers pr.is_enabled_toggle = is_enabled_row; - pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); + if (ro_is_slider || ro_is_numbox) + { + pr.is_slider = ro_is_slider; // slider drag bar, or ... + pr.is_stepper = ro_is_numbox; // ... num-box stepper fallback (shares the revert path) + pr.stepper_min = meta->min; + pr.stepper_max = meta->max; + pr.stepper_step = step; + pr.show_as_percentage = meta->show_as_percentage; + pr.is_percentage = meta->is_percentage; + } + else if (ro_is_enum) + { + pr.is_enum = true; + pr.enum_values = enum_values; + pr.enum_labels = enum_labels; + } + else if (ro_is_toggle) + { + pr.is_toggle = true; + } + else + { + pr.value_component = ro_value; + } // A context mismatch shows the scenario note first, then the normal description below it; an // author-disabled row shows its disabledDescription (falling back to the normal description) so the @@ -3228,46 +3333,6 @@ namespace big::mod_settings } } - // Gives action-button rows extra vertical room. The native UpdateScrollState lays every on-page row on a uniform - // 45px grid, but the Button_Secondary box is taller, so consecutive buttons would overlap. Walking the on-page rows - // top to bottom, each button is nudged down by button_extra_lead. Every row below it is shifted by - // button_extra_lead + button_extra_trail. Runs from the UpdateScrollState detour (right after the grid layout it - // undoes, and before the row hit-test in the same Update) so the hover/click rects stay aligned with the drawn - // buttons. - static void sync_button_spacing(MiscSettingsScreen* screen) - { - const std::size_t first = screen->m_page_start_index; - const std::size_t last = first + rows_per_page; - float extra = 0.0f; - for (std::size_t i = first; i < last && i < g_rows.size(); ++i) - { - GUIComponent* c = g_rows[i].component; - if (!c) - { - continue; - } - if (g_rows[i].kind == RowKind::action) - { - c->m_location_y += extra + button_extra_lead; - extra += button_extra_lead + button_extra_trail; - - // The mouse hover/click hit-test reads the button's child label location, not the button's own - // (GUIComponentButton::GetArea returns the label's text area), so move the label to the shifted button - // position or the hit rect stays on the unshifted grid slot. Absolute assignment (label follows the - // button) avoids drift: UpdateScrollState resets both to the grid each frame via the button's. - // SetLocation before this runs. - if (auto* label = *reinterpret_cast(reinterpret_cast(c) + button_label_offset)) - { - label->m_location_y = c->m_location_y; - } - } - else - { - c->m_location_y += extra; - } - } - } - // The component the user is currently on: the mouse-over one (mouse) takes priority, else the selected one // (keyboard/controller). These are MenuScreen fields (flat struct view). static GUIComponent* active_row_component(MiscSettingsScreen* screen) @@ -3735,8 +3800,10 @@ namespace big::mod_settings if (row.is_slider) { - // Moused-over look lives on the left label textbox. Revert it unless this row is the live mouse-over. - if (row.component != menu->m_mouse_over_component) + // Moused-over look lives on the left label textbox. Revert it unless this row is the live mouse-over - + // but a disabled (greyed, still-selectable) row is reverted even while hovered, so its bar/label never + // light up: it must read as greyed no matter the cursor. + if (row.disabled || row.component != menu->m_mouse_over_component) { if (auto* label = *reinterpret_cast(s + slider_label_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) { @@ -3744,17 +3811,19 @@ namespace big::mod_settings } } - // Focused look lives on mFocused/the value textbox. Revert it unless this row is the focused option. - if (row.component != screen->m_component_focused && *reinterpret_cast(s + slider_focused_offset)) + // Focused look lives on mFocused/the value textbox. Revert it unless this row is the focused option (a + // disabled row is never focused, so it is always reverted here). + if ((row.disabled || row.component != screen->m_component_focused) && *reinterpret_cast(s + slider_focused_offset)) { call_component_vfn(row.component, vtable_on_focus_off_offset); } } - else if ((row.is_enum || row.is_stepper) && mouse_mode) + else if ((row.is_enum || row.is_stepper) && (mouse_mode || row.disabled)) { // Num-box selected look (black box + green label) set by OnSelected, on the label textbox mUseSelected - // flag. Revert via OnUnselected (not OnMouseOff, a no-op here) unless it is the live mouse-over. - if (row.component != menu->m_mouse_over_component) + // flag. Revert via OnUnselected (not OnMouseOff, a no-op here) unless it is the live mouse-over - a + // disabled (greyed, still-selectable) row is reverted even while hovered so it stays greyed. + if (row.disabled || row.component != menu->m_mouse_over_component) { if (auto* label = *reinterpret_cast(s + numbox_label_text_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) { @@ -3765,6 +3834,50 @@ namespace big::mod_settings } } + // Keeps a greyed-but-still-selectable widget row's label (and value) text greyed. Such a row keeps mIsUseable=1 so + // the mouse can still hover it for its note, but a widget's Draw writes the label text box's colour flags from + // mIsUseable every frame, clearing the disabled flag so the label falls back to its cached bright mTextColor (set + // once from the bright template def at build - greying the def afterwards does not update the cached value). We + // re-apply the grey through the text box's own SetTextColor each frame (the same call the engine's + // UpdateButtonStates uses to grey a still-hoverable option), which writes that cached mTextColor directly. The + // selected-colour def is greyed too (grey_text_box) so a hover that briefly sets the selected flag stays grey. + static void keep_disabled_labels_grey() + { + const auto grey_label = [](char* base, std::size_t tb_offset) + { + auto* tb = *reinterpret_cast(base + tb_offset); + if (!tb) + { + return; + } + char* vtable = *reinterpret_cast(tb); + auto fn = *reinterpret_cast(vtable + vtable_set_text_color_offset); + fn(tb, disabled_label_grey_packed); + }; + for (const auto& row : g_rows) + { + if (!row.disabled || !row.component) + { + continue; + } + char* s = reinterpret_cast(row.component); + if (row.is_slider) + { + grey_label(s, slider_label_offset); + grey_label(s, slider_value_text_offset); + } + else if (row.is_enum || row.is_stepper) + { + grey_label(s, numbox_label_text_offset); + grey_label(s, numbox_value_text_offset); + } + else if (row.is_toggle) + { + grey_label(s, button_label_offset); + } + } + } + // Makes the keyboard/controller spatial nav skip every disabled/greyed row so DOWN/UP jumps straight to the next // interactable one (with the native wrap and cross-page paging), while leaving mouse hover untouched so a mouse // user can still rest on a greyed row to read its description. It clears mData.mDef.mFreeFormSelectable on each @@ -3888,9 +4001,7 @@ namespace big::mod_settings // A view change leaves the freshly built rows at mFadeOpacity 0 (finalize_row). The native ease // (GUIComponent::Update) then fades the on-page rows in toward mFadeTarget == 1, matching the game's own // category-switch transition. Off-page rows are held transparent in sync_scroll_fade. Value displays are not - // laid out by the scroll pass place them on their key rows now. The action-button vertical spacing is applied - // in the UpdateScrollState detour (which the direct g_update_scroll call above routes through), so the key rows - // are already shifted here. + // laid out by the scroll pass place them on their key rows now. sync_value_columns(); // Take the disabled/greyed rows out of the keyboard/controller nav so the cursor only lands on interactable @@ -4766,8 +4877,8 @@ namespace big::mod_settings // (fade target 0), so from the last on-page row the ray finds nothing below and cannot advance. We make each arrow // the target instead by placing its eval point exactly where the next (down) or previous (up) row would be: one // row_pitch beyond the actual last/first visible row, at that row's location. Because the offset is set relative - // to the arrow's own location, any shared parent offset cancels, so the eval point tracks the real row position - // even after the action-button spacing shifts rows. The arrow's own auto-activate then fires ScrollDown/ScrollUp + // to the arrow's own location, any shared parent offset cancels, so the eval point tracks the real row position. + // The arrow's own auto-activate then fires ScrollDown/ScrollUp // when the nav lands on it. Off the last/first page the arrow is hidden and unselectable, so this is inert there. static void enable_arrow_keyboard_paging(MiscSettingsScreen* screen) { @@ -4801,10 +4912,8 @@ namespace big::mod_settings } // Detour on the native scroll pass. The original lays every on-page row on the uniform grid (writing each row's - // mLocation), so it is the point where our action-button spacing must be (re)applied: running it here, inside - // MiscSettingsScreen::Update BEFORE the row hit-test in MenuScreen::Update, keeps the hover/click rects aligned - // with the drawn (shifted) buttons. Applying the shift after the original Update instead left the hit-test on the - // unshifted grid, so a button's hover box sat above its visual and bled into the row above. + // mLocation). We hook it to re-aim the scroll arrows' keyboard-nav eval points at the new page edges after each + // layout (see enable_arrow_keyboard_paging), inside MiscSettingsScreen::Update before the row hit-test. static void hook_MiscSettingsScreen_UpdateScrollState(void* self) { big::g_hooking->get_original()(self); @@ -4813,7 +4922,6 @@ namespace big::mod_settings const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); if (on_mods_tab) { - sync_button_spacing(screen); enable_arrow_keyboard_paging(screen); } } @@ -4941,10 +5049,13 @@ namespace big::mod_settings // native prompts alone). sync_prompts(screen, on_mods_tab); - // Revert any slider/num-box highlight left stranded on the wrong row by a rebuild or the hover re-assert. + // Revert any slider/num-box highlight left stranded on the wrong row by a rebuild or the hover re-assert, and + // re-assert the greyed-label colour flag on disabled-but-selectable widget rows (the widgets clear it each + // frame from mIsUseable). if (on_mods_tab) { clear_stale_widget_highlight(screen); + keep_disabled_labels_grey(); } return result; From 0f1812a2a4bff0c818560fa7762763eb6f58bfae Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:01:54 +0100 Subject: [PATCH 061/100] Restore action-button vertical spacing drift-free via SetLocation --- src/hades2/mod_settings/mod_settings.cpp | 65 +++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 413a630..1705bf6 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -222,6 +222,7 @@ namespace big::mod_settings static constexpr std::size_t vtable_on_mouse_off_offset = 0x00'60; // GUIComponent::OnMouseOff slot static constexpr std::size_t vtable_on_unselected_offset = 0x00'88; // GUIComponent::OnUnselected slot static constexpr std::size_t vtable_on_focus_off_offset = 0x1'18; // GUIComponent::OnFocusOff slot + static constexpr std::size_t vtable_set_location_offset = 0x1'80; // GUIComponent::SetLocation slot (moves the component and its children) // Disabled-greying of a slider/num-box, which are multi-sub-component widgets: the button-style def text greying // does not reach their separate label/value text boxes or their bar/arrow graphics, so each is greyed directly. @@ -389,6 +390,13 @@ namespace big::mod_settings static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) static constexpr std::uint32_t rows_per_page = 10; // vanilla ItemsPerPage = 10 + // Action-button rows use the taller Button_Secondary box, which crowds the neighbouring setting rows on the uniform + // row pitch. apply_button_spacing nudges each action button down by this lead and shifts the rows below it by + // lead+trail, giving the buttons vertical breathing room (applied through the engine's own SetLocation, so it is + // drift-free - see apply_button_spacing). + static constexpr float button_extra_lead = 14.0f; + static constexpr float button_extra_trail = 14.0f; + // Config sections. Both rom.mod_settings.load and Chalk bind a mod's settings under the root "config" section. // Nested groups are dot-separated child sections (e.g. "config.biome_pool"). static const std::string root_section = "config"; @@ -3772,6 +3780,21 @@ namespace big::mod_settings reinterpret_cast(fn)(comp); } + // Moves a row (and its owned child components) to an absolute location via the engine's own SetLocation (GUIComponent + // vtable slot +0x180) - the same call UpdateScrollState uses to lay rows on the grid. Going through SetLocation + // (rather than writing m_location_y directly) keeps a row's children - a slider's bar/label, a button's label - in + // step, avoiding the per-frame drift a raw location write causes. The native call passes the Vector2 packed in one + // 64-bit register (y in the high half, x in the low), which we reproduce here. + static void set_component_location(GUIComponent* comp, float x, float y) + { + char* vtable = *reinterpret_cast(comp); + auto fn = *reinterpret_cast(vtable + vtable_set_location_offset); + std::uint32_t xb, yb; + std::memcpy(&xb, &x, sizeof xb); + std::memcpy(&yb, &y, sizeof yb); + fn(comp, (static_cast(yb) << 32) | xb); + } + // Reverts a stale highlight left on the wrong slider or num-box row. Unlike a button, these have no Draw-time // highlight gate: their lit look is child state set by an OnXxxOn handler and undone only by the matching OnXxxOff, // never re-derived in Draw. A rebuild or our hover re-assert (which writes mMouseOverComponent directly, bypassing @@ -4871,6 +4894,42 @@ namespace big::mod_settings return result; } + // Restores vertical breathing room around action-button rows (Apply/Reset), whose taller Button_Secondary box would + // otherwise crowd the neighbouring setting rows on the uniform grid. Run right after the native UpdateScrollState + // has laid every on-page row on the grid: within the current page we nudge each action button down by + // button_extra_lead and shift the rows below it by lead+trail (accumulated). The shift is applied through the + // engine's own SetLocation so each row's child components follow (a raw m_location_y write leaves them behind, which + // is what drove the earlier slider-bar drift). It is page-aware and reapplied every frame off the freshly-gridded + // positions, so it never accumulates. + static void apply_button_spacing(MiscSettingsScreen* screen) + { + const std::size_t first = screen->m_page_start_index; + const std::size_t last = first + rows_per_page; + float extra = 0.0f; + for (std::size_t i = first; i < last && i < g_rows.size(); ++i) + { + GUIComponent* c = g_rows[i].component; + if (!c) + { + continue; + } + float shift; + if (g_rows[i].kind == RowKind::action) + { + shift = extra + button_extra_lead; + extra += button_extra_lead + button_extra_trail; + } + else + { + shift = extra; + } + if (shift != 0.0f) + { + set_component_location(c, c->m_location_x, c->m_location_y + shift); + } + } + } + // Points the native scroll arrows at the keyboard/controller nav so it can page. The spatial search // (SearchInDirection) walks a ray from the selected row in the pressed direction and picks the nearest selectable // component whose eval point (location + mFreeFormSelectOffset) is close to the ray. Off-page rows are unselectable @@ -4912,8 +4971,9 @@ namespace big::mod_settings } // Detour on the native scroll pass. The original lays every on-page row on the uniform grid (writing each row's - // mLocation). We hook it to re-aim the scroll arrows' keyboard-nav eval points at the new page edges after each - // layout (see enable_arrow_keyboard_paging), inside MiscSettingsScreen::Update before the row hit-test. + // mLocation). We hook it to give the action-button rows their vertical breathing room (apply_button_spacing) and to + // re-aim the scroll arrows' keyboard-nav eval points at the new page edges after each layout (see + // enable_arrow_keyboard_paging), inside MiscSettingsScreen::Update before the row hit-test. static void hook_MiscSettingsScreen_UpdateScrollState(void* self) { big::g_hooking->get_original()(self); @@ -4922,6 +4982,7 @@ namespace big::mod_settings const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); if (on_mods_tab) { + apply_button_spacing(screen); enable_arrow_keyboard_paging(screen); } } From 0a20ff8b3795eea3436bc042f2e0d25bd7cb5b64 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:12:44 +0100 Subject: [PATCH 062/100] Make active mod-settings action buttons keyboard/controller navigable --- src/hades2/mod_settings/mod_settings.cpp | 62 +++++++++++++++++++----- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 1705bf6..5dbc7ac 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -341,11 +341,18 @@ namespace big::mod_settings static slider_set_fraction_fn g_slider_set_fraction = nullptr; static std::uintptr_t g_slider_vtable = 0; // runtime // A patched copy of the slider vtable (built in set_up_hooks) whose GetArea/GetScreenArea slots return a one-row - // hit rect (see slider_bounded_area), replacing the native ones that union the slider's sub-components into a + // hit rect (see row_bounded_area), replacing the native ones that union the slider's sub-components into a // screen-spanning rect. 128 slots comfortably covers the class's virtual table. static constexpr std::size_t slider_vtable_slot_count = 128; static std::uintptr_t g_slider_vtable_copy[slider_vtable_slot_count] = {}; static std::uintptr_t g_slider_vtable_patched = 0; // runtime + + // A patched copy of the GUIComponentButton vtable (built lazily in install_wide_button_nav_rect from the first + // action button's vtable) whose GetArea/GetScreenArea slots return the same wide one-row rect (row_bounded_area), + // so a centre-column action button is reachable by the vertical spatial nav. Installed only on enabled action + // buttons; every other button row keeps the native vtable. + static std::uintptr_t g_button_vtable_copy[slider_vtable_slot_count] = {}; + static std::uintptr_t g_button_vtable_patched = 0; // runtime static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) static set_mouse_over_fn g_set_mouse_over = nullptr; // MenuScreen::SetMouseOver (highlight + select a row) static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) @@ -1160,6 +1167,7 @@ namespace big::mod_settings // hard-disabled (non-selectable). Pass block_input=false to grey a row while keeping it selectable, so it can still // be highlighted to show its description note (used for a context-restricted action, which is greyed but must still // explain why. It is unavailable). + static void install_wide_button_nav_rect(GUIComponent* row); // defined below (near row_bounded_area) static GUIComponent* make_button_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) { auto* row = create_button(screen); @@ -1247,6 +1255,18 @@ namespace big::mod_settings g_disable(row); } + // The CategoryOptionsButton template is shared with the top category tabs (paged by bumpers, not the vertical + // option nav), so it leaves mData.mDef.mFreeFormSelectable unset - meaning the up/down spatial nav + // (SearchInDirection) skips it. Opt an enabled action button in, and give it a wide option-column nav rect + // (install_wide_button_nav_rect): its native GetArea is a narrow rect at the centered label, which a vertical + // nav ray down the option column never crosses, so nav would still skip it. A disabled action stays skipped + // (apply_row_freeform_selectability clears the flag for every disabled row after the build). + if (!disabled) + { + *reinterpret_cast(row_bytes + component_free_form_selectable_offset) = true; + install_wide_button_nav_rect(row); + } + finalize_row(screen, row); // Centre the button in the content pane (finalize_row anchors rows at the right-hand option column, which would @@ -1506,14 +1526,16 @@ namespace big::mod_settings // bounded numeric setting. The slider stores a normalized 0..1 fraction. We map the setting's [min,max] onto it and // snap drags to `step` in the SetFraction hook. The engine has no factory for this type, so this replicates the // construction DoShowCategory performs for the volume rows: allocate the block, run the base GUIComponent - // Row-sized hover/nav hit rect for our interactive slider rows, installed via the patched slider vtable - // (GetArea vtable +0x98 and GetScreenArea +0xA0). The native GUIComponentSlider::GetArea unions the slider's - // sub-components (bar, fill, label, value) into a rectangle spanning most of the screen; via the nearest-anchor - // tiebreak in MenuScreen::UpdateMouseOver that giant rect steals mouse hover from every other row on a page mixing - // sliders with other row types (and the polluted mouse-over then overrides keyboard nav). Returning a one-row rect - // (matching the toggle/text rows' footprint) makes the slider hit-test like any other row. Dragging is unaffected: - // it runs through GUIComponentSlider::HandleInput (hooked separately), not GetArea. IRectangle is {x,y,w,h} int32. - static void* slider_bounded_area(GUIComponent* self, std::int32_t* out) + // Row-sized hover/nav hit rect for our custom rows whose native GetArea is unsuitable, installed via a patched + // vtable on the GetArea (+0x98) and GetScreenArea (+0xA0) slots. Two rows need it: interactive sliders (whose + // native GUIComponentSlider::GetArea unions the bar/fill/label/value sub-components into a near screen-spanning + // rectangle that steals mouse hover from every other row via the nearest-anchor tiebreak in + // MenuScreen::UpdateMouseOver), and centered action buttons (whose GUIComponentButton::GetArea is derived from the + // CENTERED label, a narrow rect at the button centre that a vertical nav ray down the option column never crosses, + // so the up/down nav skips them). Returning a one-row rect spanning the option column makes both hit-test and + // nav-test like any other row (the toggle/text rows' footprint). Slider dragging is unaffected: it runs through + // GUIComponentSlider::HandleInput (hooked separately), not GetArea. IRectangle is {x,y,w,h} int32. + static void* row_bounded_area(GUIComponent* self, std::int32_t* out) { const int left = static_cast(row_location_x + row_text_offset_x); // option-name column start (~660) out[0] = left; @@ -1523,6 +1545,24 @@ namespace big::mod_settings return out; } + // Installs the patched button vtable (GetArea/GetScreenArea -> row_bounded_area, a wide one-row option-column rect) + // on `row`, building the copy lazily from the row's current (native) vtable on first use. A centre-column action + // button's native GetArea is a narrow rect at the button centre that the vertical nav ray never crosses; the wide + // rect makes it reachable like any setting row. The copy is byte-identical apart from the two area getters, so the + // destructor destroy_rows invokes and every other virtual behave exactly as the native button. + static void install_wide_button_nav_rect(GUIComponent* row) + { + if (!g_button_vtable_patched) + { + const std::uintptr_t native_vtable = *reinterpret_cast(row); + std::memcpy(g_button_vtable_copy, reinterpret_cast(native_vtable), sizeof(g_button_vtable_copy)); + g_button_vtable_copy[0x98 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); + g_button_vtable_copy[0xA0 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); + g_button_vtable_patched = reinterpret_cast(g_button_vtable_copy); + } + *reinterpret_cast(row) = g_button_vtable_patched; + } + // constructor, install the slider vtable, zero the fields Defaults leaves untouched, then run Defaults and allocate // the four owned sub-components (bar background, fill, label, value text). Named "OptionSlider" so // ApplyDataToComponent applies the matching sjson template (bar graphics, colours, FadeSpeed, label styling). @@ -5445,8 +5485,8 @@ namespace big::mod_settings if (g_slider_vtable) { std::memcpy(g_slider_vtable_copy, reinterpret_cast(g_slider_vtable), sizeof(g_slider_vtable_copy)); - g_slider_vtable_copy[0x98 / sizeof(std::uintptr_t)] = reinterpret_cast(&slider_bounded_area); - g_slider_vtable_copy[0xA0 / sizeof(std::uintptr_t)] = reinterpret_cast(&slider_bounded_area); + g_slider_vtable_copy[0x98 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); + g_slider_vtable_copy[0xA0 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); g_slider_vtable_patched = reinterpret_cast(g_slider_vtable_copy); } From 5d973b8c320a79cbdeb50112410426ad42e18680 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:36:15 +0100 Subject: [PATCH 063/100] Add optional custom description to rom.mod_settings.opt_out --- .../tables/definitions/rom.mod_settings.lua | 5 +-- docs/lua/tables/rom.mod_settings.md | 7 ++-- src/hades2/mod_settings/config_api.cpp | 33 ++++++++++++++----- src/hades2/mod_settings/mod_settings.cpp | 12 ++++++- src/hades2/mod_settings/mod_settings.hpp | 5 +++ 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/docs/lua/tables/definitions/rom.mod_settings.lua b/docs/lua/tables/definitions/rom.mod_settings.lua index 23270bb..92f5725 100644 --- a/docs/lua/tables/definitions/rom.mod_settings.lua +++ b/docs/lua/tables/definitions/rom.mod_settings.lua @@ -8,7 +8,8 @@ ---@return table # A live read/write proxy over the mod's config; index it to read a setting and assign to write one. function mod_settings.load(config_lua) end --- Excludes the calling mod from the in-game mod settings menu: it stays listed but will begreyed out and +-- Excludes the calling mod from the in-game mod settings menu: it stays listed but will be greyed out and -- cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod -- should not be edited in-game. Works with Chalk or rom.mod_settings.load. -function mod_settings.opt_out() end +---@param description? string A plain string or a localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note when the mod's greyed row is highlighted. +function mod_settings.opt_out(description) end diff --git a/docs/lua/tables/rom.mod_settings.md b/docs/lua/tables/rom.mod_settings.md index 9090e46..e53d977 100644 --- a/docs/lua/tables/rom.mod_settings.md +++ b/docs/lua/tables/rom.mod_settings.md @@ -18,13 +18,16 @@ live read/write proxy over the config. When using this, you do not need to depen table = rom.mod_settings.load(config_lua) ``` -### `opt_out()` +### `opt_out(description)` Excludes the calling mod from the in-game mod settings menu: it stays listed but will be greyed out and cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. +- **Parameters:** + - `description` (string): Optional. A plain string or a localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note when the mod's greyed row is highlighted. + **Example Usage:** ```lua -rom.mod_settings.opt_out() +rom.mod_settings.opt_out("Please use the imgui menu to configure this mod (opens with \"Insert\" by default).") ``` diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index c779101..ebc9333 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -52,10 +52,11 @@ namespace big::mod_settings // stays toggleable). Keyed the same way as g_setting_metadata (guid + '\0' + section + '\0' + key). static std::set g_described_keys; - // Guids of mods that called rom.mod_settings.opt_out(), i.e. asked not to be configured through the in-game menu. - // Guarded by g_metadata_mutex. Cleared and rebuilt on each Lua-state init (see bind_config_api) because opt_out - // re-runs with each mod's main.lua. - static std::set g_opted_out_mods; + // Guids of mods that called rom.mod_settings.opt_out(), i.e. asked not to be configured through the in-game menu, + // mapped to the optional custom description the mod passed (empty when none was given). Guarded by + // g_metadata_mutex. Cleared and rebuilt on each Lua-state init (see bind_config_api) because opt_out re-runs with + // each mod's main.lua. + static std::map g_opted_out_mods; // Action buttons declared in config.lua (configDesc entries with an `action` function, no config value). Keyed by // guid, in config.lua source order. Plain data (the callable stays in the Lua-side description registry and is @@ -156,6 +157,13 @@ namespace big::mod_settings return g_opted_out_mods.count(guid) != 0; } + localized_text mod_opt_out_description(const std::string& guid) + { + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_opted_out_mods.find(guid); + return it != g_opted_out_mods.end() ? it->second : localized_text{}; + } + // Finds the byte offset of a key's definition (" =") in config.lua source, whole-word and not "==", or npos. // The first match is the key's place in the returned `config` defaults table (defined before configDesc), which is // the author's intended display order. Occurrences inside strings/prose don't match because they are not followed @@ -1806,10 +1814,12 @@ namespace big::mod_settings return any_changed; } - // Lua API: Function. Table: mod_settings. Name: opt_out. Excludes the calling mod from the in-game mod settings - // menu: it stays listed but greyed out and cannot be opened, with a note pointing the player to the mod's own - // description. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. - static void opt_out(sol::this_environment this_env) + // Lua API: Function. Table: mod_settings. Name: opt_out. Param: description: string: Optional. A plain string or a + // localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note when the mod's greyed + // row is highlighted. Excludes the calling mod from the in-game mod settings menu: it stays listed but greyed out + // and cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod should + // not be edited in-game. Works with Chalk or rom.mod_settings.load. + static void opt_out(sol::this_environment this_env, sol::object description) { // Keyed by the calling mod's guid (which matches its config-file stem), so the menu can grey the matching row // however the mod manages its config. @@ -1822,8 +1832,13 @@ namespace big::mod_settings { return; } + localized_text note; + if (description.valid() && description != sol::lua_nil) + { + note = parse_localized(description); + } std::scoped_lock lock(g_metadata_mutex); - g_opted_out_mods.insert(module->guid()); + g_opted_out_mods[module->guid()] = std::move(note); } void bind_config_api(sol::state_view& state, sol::table& lua_ext) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 5dbc7ac..1361a04 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -629,6 +629,16 @@ namespace big::mod_settings "to configure it, if applicable."; } + static std::string resolve_localized(const localized_text& t); // defined below + + // The description shown for an opted-out mod's greyed row: the author's own opt_out(description) if they supplied + // one (resolved to the current game language), otherwise the generic opt_out_note(). + static std::string opt_out_description(const std::string& stem) + { + const std::string custom = resolve_localized(mod_opt_out_description(stem)); + return !custom.empty() ? custom : opt_out_note(); + } + // Escapes the characters the game's text parser (GUIComponentTextBox::Parse) treats as markup, so arbitrary user // text - config values (e.g. Windows paths with '\'), display names and descriptions - renders verbatim instead of // being mangled. The parser reads '\' as an escape lead that consumes the following word ("D:\Program..." -> "D: @@ -1806,7 +1816,7 @@ namespace big::mod_settings { PanelRow pr{row, RowKind::mod_entry, stem, {}}; pr.disabled = opted_out; - pr.description = opted_out ? opt_out_note() : mod_description_from_stem(stem); + pr.description = opted_out ? opt_out_description(stem) : mod_description_from_stem(stem); g_rows.push_back(std::move(pr)); } } diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 97602e5..fc8811d 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -230,6 +230,11 @@ namespace big::mod_settings // the mod's main.lua). bool mod_opted_out(const std::string& guid); + // The optional custom description a mod passed to rom.mod_settings.opt_out(description) (empty when none was + // given). The settings menu shows it (resolved to the current language) in place of the generic opt-out note when + // the mod's greyed row is highlighted. + localized_text mod_opt_out_description(const std::string& guid); + // True while a setting change should notify its mod through an on_change callback: a native options screen is // currently open and it was opened in-game (a save is loaded). Consulted by the config API so an on_change fires // only for an edit made through the in-game options menu, which can be applied to the live run - never in the main From 2a917d1a0a2d4ae56667fbc73ccbc4edfc33194c Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:07:56 +0100 Subject: [PATCH 064/100] Allow custom re-nesting using groups --- docs/mod_settings/README.md | 15 + docs/mod_settings/config_schema.lua | 30 +- src/hades2/mod_settings/config_api.cpp | 92 +++++- src/hades2/mod_settings/mod_settings.cpp | 382 +++++++++++++++++------ src/hades2/mod_settings/mod_settings.hpp | 28 ++ 5 files changed, 444 insertions(+), 103 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index 9a0bb2e..181b33a 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -56,6 +56,21 @@ If you happen to name a config key after one of the reserved fields above, the m but it is highly recommended to **not** use reserved field names as config keys to prevent confusion and potential edge case breakage. +## Menu grouping (`group` and `groups`) + +The in-game menu layout can be **decoupled** from your config file structure. `configDesc` must still mirror the config +(`config.debugging.logLevel` is described at `configDesc.debugging.logLevel`), but where each row *appears* in the menu +is independent: + +- By default a row appears under its **config section** - so a nested config nests in the menu automatically. +- Add a **`group`** property to any entry (setting, action, or virtual row) to move it into a different menu + category. It is a string for a single level, or an array for a nested path. This works for flat *and* nested config + keys, and doesn't change where the value is stored in the .cfg file. +- Declare menu categories that do **not** exist as config sections in a top-level **`groups`** table (keyed by the id + used in a `group`), each with an optional `displayName`, `description`, `order`, and nested `groups`. + +This lets you keep a flat config but present any grouping you like, or re-nest an already-nested config another way. + ## Dynamic fields (functions) Most fields can also be dynamically resolved through a function call, which is evaluated when the menu diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 132eb7e..0170dd0 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -12,6 +12,27 @@ ---@alias mod_settings.dynamic_boolean boolean | fun(): boolean ---@alias mod_settings.dynamic_string mod_settings.localized_string | fun(): mod_settings.localized_string +--- A menu placement path. The in-game menu layout is decoupled from the config file structure: by default a +--- setting appears under its config section (so a nested config nests in the menu), but a `group` moves it into +--- a different or brand-new menu category instead. A single string is a one-level group; an array is a nested +--- path (e.g. { "Debugging", "Logging" }). configDesc must still mirror the config structure (debugging.logLevel +--- in config is debugging.logLevel in configDesc). Supplying `group` only changes where a row is shown, not wherer +--- its value lives in the .cfg file. +---@alias mod_settings.group string | string[] + +--- A menu category declared in the top-level configDesc `groups`, letting a flat (or differently nested) config +--- be presented under an arbitrary menu tree. Only needed for categories that are not config sections already. +---@class (exact) mod_settings.menu_group +--- Category label shown on its drill-down row. Defaults to a prettified version of the group's key. +---@field displayName? mod_settings.localized_string +--- Help text shown while the category's row is highlighted. +---@field description? mod_settings.localized_string +--- Sort key among sibling categories/rows, lower first. +---@field order? number +--- Nested sub-categories, keyed by their id (referenced as later path segments in a `group`). +---@field groups? table + + --- Describes how a config option appears in the in-game mod settings menu. Every field is optional. The --- widget type is inferred from the setting's config value (a boolean becomes a toggle; a number with `min` --- and `max` becomes a slider; a value with `values` becomes a cycler; anything else is a free-text field). @@ -63,6 +84,8 @@ --- and the new value. Use it to apply the change to the loaded run. It is not called in the main menu. --- Re-writing the same value is a no-op and does not fire. Errors are logged, not propagated. ---@field onChange? fun(key: string, new_value: boolean|number|string) +--- Move this row to a different or new menu category, overriding its config-section placement (see mod_settings.group). +---@field group? mod_settings.group --- An action button in the menu that runs a callback instead of editing a config value. Declare it as a --- `configDesc` entry (with a matching key that has NO config value) carrying an `action` function. @@ -84,6 +107,8 @@ --- explain why it is unavailable. Ignored for a context-restricted row (only editable in main menu etc.) or --- while the whole mod is disabled. Defaults to the normal `description` when omitted. ---@field disabledDescription? mod_settings.dynamic_string +--- Move this button to a different or new menu category, overriding its config-section placement (see mod_settings.group). +---@field group? mod_settings.group --- A virtual row: a menu row that is NOT backed by a `config` value, whose value comes from Lua callbacks. --- Declare it as a `configDesc` entry whose key has NO matching `config` value, with `virtual = true` (required, @@ -147,10 +172,13 @@ --- Sort key for custom ordering config entries in the menu, lower first. --- When omitted, rows keep the order they are defined in the default config you provide. ---@field order? number +--- Move this row to a different or new menu category, overriding its config-section placement (see mod_settings.group). +---@field group? mod_settings.group --- Each entry in `configDesc` can be a simple key:description string, a setting description table, an action --- button, or a nested table of descriptions mirroring a config group. The underlying .cfg file contents are ---- not changed by this format. +--- not changed by this format. A top-level `groups` table (see mod_settings.menu_group) may declare menu +--- categories that do not exist as config sections, which entries move into via their `group`. --- --- Only keys with a `configDesc` entry are shown in the menu: a `config` key with no entry here is treated as --- internal state and hidden (a group whose keys are all undescribed produces no row). The mod's master diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index ebc9333..11708f2 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -68,6 +68,11 @@ namespace big::mod_settings // description registry and are resolved at render. Cleared per-mod in clear_metadata_for. static std::map> g_virtual_rows; + // Author-declared menu group trees (top-level configDesc `groups`), keyed by guid. These are the menu categories a + // per-entry `group` can reference that do not correspond to a config section. Cleared each Lua-state init in + // bind_config_api. mod_menu_groups returns an empty tree for mods that declared none. + static std::map> g_menu_groups; + // The config section every mod's settings are bound under (matches SGG_Modding-Chalk, keeps the .cfg // byte-compatible). Description tables in config.lua mirror the config table under this root. static constexpr const char* root_section = "config"; @@ -164,6 +169,13 @@ namespace big::mod_settings return it != g_opted_out_mods.end() ? it->second : localized_text{}; } + std::vector mod_menu_groups(const std::string& guid) + { + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_menu_groups.find(guid); + return it != g_menu_groups.end() ? it->second : std::vector{}; + } + // Finds the byte offset of a key's definition (" =") in config.lua source, whole-word and not "==", or npos. // The first match is the key's place in the returned `config` defaults table (defined before configDesc), which is // the author's intended display order. Occurrences inside strings/prose don't match because they are not followed @@ -273,6 +285,65 @@ namespace big::mod_settings return {}; } + // Parses a configDesc `group` field into a menu path (the ordered group segments the entry is moved under). Accepts + // a plain string (a single-level group) or an array of strings (a nested path). Non-string entries are ignored. + // Empty result means no override (the entry keeps its config-section placement). + static std::vector parse_group(const sol::object& o) + { + std::vector out; + if (o.get_type() == sol::type::string) + { + out.push_back(o.as()); + } + else if (o.is()) + { + sol::table t = o.as(); + for (std::size_t i = 1; i <= t.size(); ++i) + { + sol::object seg = t[i]; + if (seg.get_type() == sol::type::string) + { + out.push_back(seg.as()); + } + } + } + return out; + } + + // Recursively parses a configDesc `groups` table into menu_group nodes. Each key is a group's identity (used in a + // `group` path); its value is a metadata table carrying an optional localized `displayName`/`description`, a numeric + // `order`, and a nested `groups` table of sub-groups. Non-table values are skipped. Order among siblings: explicit + // `order` first (ascending), then declaration order is unspecified in Lua, so callers fall back to the id. + static std::vector parse_menu_groups(const sol::object& groups_obj) + { + std::vector out; + if (!groups_obj.is()) + { + return out; + } + groups_obj.as().for_each( + [&out](const sol::object& k, const sol::object& v) + { + if (k.get_type() != sol::type::string || !v.is()) + { + return; + } + sol::table gt = v.as(); + menu_group g; + g.id = k.as(); + g.name = parse_localized(gt["displayName"]); + g.description = parse_localized(gt["description"]); + if (sol::object order = gt["order"]; order.get_type() == sol::type::number) + { + g.has_order = true; + g.order = order.as(); + } + g.children = parse_menu_groups(gt["groups"]); + out.push_back(std::move(g)); + }); + return out; + } + // True if a config.lua description table declares `restartRequired = true`. static bool description_requires_restart(const sol::object& desc) { @@ -485,6 +556,9 @@ namespace big::mod_settings } } + // Menu placement override: the author-declared `group` this entry appears under instead of its config section. + m.group = parse_group(desc["group"]); + return m; } @@ -610,6 +684,7 @@ namespace big::mod_settings a.disabled = d.as(); } a.context = parse_editable_context(entry["editableContext"], editable_context::any); + a.group = parse_group(entry["group"]); } // Walks a mod's configDesc (guided by the config defaults structure, like bind_defaults) collecting action buttons: @@ -691,6 +766,8 @@ namespace big::mod_settings "text", "type", "default", + "group", // per-entry menu placement override + "groups", // top-level author group-tree declaration (root configDesc only) }; return reserved.contains(key); } @@ -767,6 +844,7 @@ namespace big::mod_settings virtual_row_info vr; vr.section = section; vr.key = key; + vr.group = parse_group(entry["group"]); if (sol::object o = entry["order"]; o.get_type() == sol::type::number) { vr.has_order = true; @@ -1375,6 +1453,14 @@ namespace big::mod_settings collect_virtual_rows(guid, defaults.as(), descriptions, root_section, virtual_rows); } + // Parse the author-declared menu group tree (top-level configDesc `groups`), the categories a per-entry `group` + // can target that do not exist as config sections. + std::vector menu_groups; + if (descriptions.is()) + { + menu_groups = parse_menu_groups(descriptions.as()["groups"]); + } + // Read config.lua source to recover the author's key order (Lua pairs() and the alphabetical config map both // lose it), then rank every bound key by where it is defined. std::string source_text; @@ -1450,6 +1536,7 @@ namespace big::mod_settings } g_actions[guid] = std::move(actions); g_virtual_rows[guid] = std::move(virtual_rows); + g_menu_groups[guid] = std::move(menu_groups); } return make_proxy(ts, cf.get(), "config"); @@ -1501,7 +1588,7 @@ namespace big::mod_settings } for (const auto& a : it->second) { - if (a.section == section) + if (section.empty() || a.section == section) // empty section = all sections (menu-path bucketing) { result.push_back(a); } @@ -1568,7 +1655,7 @@ namespace big::mod_settings } for (const auto& vr : it->second) { - if (vr.section == section) + if (section.empty() || vr.section == section) // empty section = all sections (menu-path bucketing) { result.push_back(vr); } @@ -1855,6 +1942,7 @@ namespace big::mod_settings g_opted_out_mods.clear(); g_actions.clear(); g_virtual_rows.clear(); + g_menu_groups.clear(); g_described_keys.clear(); } diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 1361a04..dc4da07 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -483,6 +484,12 @@ namespace big::mod_settings // Group rows (RowKind::group) only: the child config section this row drills into. std::string target_section; + + // The entry's REAL config section (for virtual-row Lua I/O: get/set/text). A `group` override can place a row + // on a menu page whose path differs from the entry's config section, so runtime commits must use this, not the + // view path. Config settings carry their section on `entry`; actions on `target_section`; only virtual rows + // need this stored. Empty -> falls back to the current view path. + std::string config_section; }; static std::vector g_rows; @@ -2248,13 +2255,14 @@ namespace big::mod_settings } else if (row->is_virtual_input) { - const auto cur = get_virtual_value(row->stem, g_view_section, row->setting_key); + const std::string& vsec = !row->config_section.empty() ? row->config_section : g_view_section; + const auto cur = get_virtual_value(row->stem, vsec, row->setting_key); if (!(cur.type == virtual_value::kind::boolean && cur.as_bool == v)) { virtual_value nv; nv.type = virtual_value::kind::boolean; nv.as_bool = v; - set_virtual_value(row->stem, g_view_section, row->setting_key, nv); + set_virtual_value(row->stem, vsec, row->setting_key, nv); changed = true; } } @@ -2280,13 +2288,14 @@ namespace big::mod_settings } else if (row->is_virtual_input) { - const auto cur = get_virtual_value(row->stem, g_view_section, row->setting_key); + const std::string& vsec = !row->config_section.empty() ? row->config_section : g_view_section; + const auto cur = get_virtual_value(row->stem, vsec, row->setting_key); if (!(cur.type == virtual_value::kind::number && cur.as_number == v)) { virtual_value nv; nv.type = virtual_value::kind::number; nv.as_number = v; - set_virtual_value(row->stem, g_view_section, row->setting_key, nv); + set_virtual_value(row->stem, vsec, row->setting_key, nv); changed = true; } } @@ -2314,13 +2323,14 @@ namespace big::mod_settings } else if (row->is_virtual_input) { - const auto cur = get_virtual_value(row->stem, g_view_section, row->setting_key); + const std::string& vsec = !row->config_section.empty() ? row->config_section : g_view_section; + const auto cur = get_virtual_value(row->stem, vsec, row->setting_key); if (!(cur.type == virtual_value::kind::string && cur.as_string == serialized)) { virtual_value nv; nv.type = virtual_value::kind::string; nv.as_string = serialized; - set_virtual_value(row->stem, g_view_section, row->setting_key, nv); + set_virtual_value(row->stem, vsec, row->setting_key, nv); changed = true; } } @@ -2577,6 +2587,63 @@ namespace big::mod_settings return cfg->try_get_entry(def) != nullptr; } + // Menu paths from a `group` override that resolved to neither a config section nor a declared category, already + // warned about (keyed "\0"), so the per-frame rebuild logs each bad target only once. + static std::set g_warned_group_overrides; + + // The menu path an entry appears at: its `group` override (resolved to a root_section-rooted dotted path) if it has + // one, else the entry's own config section. Author-group segments and config-section names share this path space, + // so navigation, bucketing and RowIdentity all keep using dotted-string paths. + static std::string menu_path_of(const std::string& config_section, const std::vector& group) + { + if (group.empty()) + { + return config_section; + } + std::string p = root_section; + for (const auto& seg : group) + { + p.push_back('.'); + p.append(seg); + } + return p; + } + + // Finds an author-declared menu group (configDesc `groups`) by its full menu path (walking the tree by the segments + // after root_section), or nullptr if the path names no author group (e.g. it is a config section instead). + static const menu_group* find_author_group(const std::vector& tree, const std::string& menu_path) + { + const std::string prefix = std::string(root_section) + "."; + if (menu_path.rfind(prefix, 0) != 0) + { + return nullptr; + } + std::string rest = menu_path.substr(prefix.size()); + const std::vector* level = &tree; + const menu_group* found = nullptr; + while (!rest.empty()) + { + const auto dot = rest.find('.'); + const std::string seg = rest.substr(0, dot); + found = nullptr; + for (const auto& g : *level) + { + if (g.id == seg) + { + found = &g; + break; + } + } + if (!found) + { + return nullptr; + } + level = &found->children; + rest = (dot == std::string::npos) ? std::string{} : rest.substr(dot + 1); + } + return found; + } + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render // as setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). Each direct child // section renders as a group row that drills into it. At the root section a boolean "enabled" entry (if present) is @@ -2590,7 +2657,11 @@ namespace big::mod_settings bool is_group = false; std::string key; // leaf key, or the group's last path segment toml_v2::config_file::config_entry_base* entry = nullptr; // leaf only - std::string child_section; // group only (full "config.x.y" path) + std::string child_section; // group only (full menu path, e.g. "config.x.y") + std::string config_section; // the entry's REAL config section (for virtual I/O; group: its parent config section) + bool is_author_group = false; // group only: declared in configDesc `groups` (not a config section) + localized_text author_name; // author-group display name (is_author_group only) + localized_text author_description; // author-group description (is_author_group only) bool has_order = false; double order = 0.0; int appearance = INT_MAX; // config.lua source rank (fallback order) @@ -2602,11 +2673,108 @@ namespace big::mod_settings }; std::vector items; - std::map groups; // child section path -> group item (keeps its min appearance). + std::map groups; // child menu path -> group item (keeps its min appearance). toml_v2::config_file::config_entry_base* enabled_entry = nullptr; toml_v2::config_file* view_cfg = nullptr; // this mod's config file (for child lookups) const std::string section_prefix = section + "."; + // The author-declared menu groups (configDesc `groups`) - the categories a per-entry `group` can target that do + // not exist as config sections. Looked up when a child group is created to pick its display name/order/source. + const std::vector author_groups = mod_menu_groups(stem); + + // Resolves an entry's menu path: its `group` override (validated) else its config section. A `group` that names + // neither a declared author group nor a config section is logged once and falls back to the config-section + // placement, so a typo leaves the row visible where its value lives rather than stranding it in a bogus group. + auto resolve_menu_path = [&](const std::string& csection, const std::vector& group) -> std::string + { + if (group.empty()) + { + return csection; + } + const std::string m = menu_path_of(csection, group); + if (find_author_group(author_groups, m)) + { + return m; // a declared author group (the common case) + } + if (view_cfg) // or an existing config section the row is being merged into + { + const std::string desc_prefix = m + "."; + for (const auto& [k, e] : view_cfg->m_entries) + { + if (k.m_section == m || k.m_section.rfind(desc_prefix, 0) == 0) + { + return m; + } + } + } + const std::string warn_key = stem + '\0' + m; + if (g_warned_group_overrides.insert(warn_key).second) + { + LOG(WARNING) << "[mod_settings] " << stem << ": `group` target '" << m << "' is neither a config section nor a category declared in configDesc `groups`; the row falls back to its config-section placement. Declare it in `groups` if it is a new menu category."; + } + return csection; + }; + + // Where an entry (living in config section `csection`, with an optional `group` override) sits relative to the + // current view `section`: 0 = not on this page (skip), 1 = a direct row here, 2 = inside a child group (its full + // menu path returned in child_out). The entry's menu path is its `group` override else its config section, so a + // flat config can be regrouped and a nested one re-nested without moving the actual config value. + auto placement = [&](const std::string& csection, const std::vector& group, std::string& child_out) -> int + { + const std::string m = resolve_menu_path(csection, group); + if (m == section) + { + return 1; + } + if (m.rfind(section_prefix, 0) == 0) + { + const std::string rest = m.substr(section_prefix.size()); + child_out = section_prefix + rest.substr(0, rest.find('.')); + return 2; + } + return 0; + }; + + // Creates (or ranks lower) the child group row at menu path `child_path`. A group declared in configDesc + // `groups` (find_author_group) takes its name/order/description from there; otherwise it is a config-derived + // group whose metadata comes from its configDesc entry at the matching config section (resolved in the render). + auto ensure_group = [&](const std::string& child_path, int app) + { + if (const auto git = groups.find(child_path); git != groups.end()) + { + if (app < git->second.appearance) + { + git->second.appearance = app; + } + return; + } + panel_item g; + g.is_group = true; + g.child_section = child_path; + g.appearance = app; + g.key = child_path.substr(child_path.rfind('.') + 1); // the child's last path segment + if (const menu_group* ag = find_author_group(author_groups, child_path)) + { + g.is_author_group = true; + g.author_name = ag->name; + g.author_description = ag->description; + if (ag->has_order) + { + g.has_order = true; + g.order = ag->order; + } + } + else if (const auto meta = resolved_metadata(stem, section, g.key); meta && meta->has_order && !config_child_exists(view_cfg, child_path, "order")) + { + // Config-derived group: its menu path equals its config section and the view is its parent section, so + // its metadata is configDesc.
. (resolved here for order, and again in the render for the + // name/description). Defer the order to a real config child named "order" (see config_child_exists). + g.has_order = true; + g.order = meta->order; + } + groups.emplace(child_path, std::move(g)); + }; + for (auto* cfg : toml_v2::config_file::g_config_files) { if (!cfg || cfg->m_config_file_stem_as_str != stem) @@ -2628,24 +2796,30 @@ namespace big::mod_settings enabled_entry = entry.get(); } - if (key.m_section == section) + // Hide config keys that carry no configDesc entry, so a mod's internal or bookkeeping values do not + // clutter its settings page. A key counts as described if it has metadata/a description from our loader + // (g_described_keys) or a plain description string bound by Chalk (entry_has_description). The one + // exception is the master "enabled" toggle, always shown so the mod stays toggleable even when its + // author did not describe it. + const bool is_enabled_toggle = key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key); + if (!is_enabled_toggle && !setting_is_described(stem, key.m_section, key.m_key) && !entry_has_description(entry.get())) { - // Hide config keys that carry no configDesc entry, so a mod's internal or bookkeeping values do not - // clutter its settings page. A key counts as described if it has metadata/a description from our - // loader (g_described_keys) or a plain description string bound by Chalk (entry_has_description). - // The one exception is the master "enabled" toggle, always shown so the mod stays toggleable even - // when its author did not describe it. - const bool is_enabled_toggle = key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key); - if (!is_enabled_toggle && !setting_is_described(stem, key.m_section, key.m_key) - && !entry_has_description(entry.get())) - { - continue; - } + continue; + } + // The `group` override is a static field, so the cheap (no-Lua) stored metadata resolves the entry's + // menu placement. Everything else (order, name, widget) still uses the entry's real config section. + const auto static_meta = get_setting_metadata(stem, key.m_section, key.m_key); + const std::vector grp = static_meta ? static_meta->group : std::vector{}; + std::string child_path; + const int place = placement(key.m_section, grp, child_path); + if (place == 1) + { panel_item it; - it.key = key.m_key; - it.entry = entry.get(); - it.appearance = get_setting_appearance_order(stem, key.m_section, key.m_key); + it.key = key.m_key; + it.entry = entry.get(); + it.config_section = key.m_section; + it.appearance = get_setting_appearance_order(stem, key.m_section, key.m_key); if (const auto meta = resolved_metadata(stem, key.m_section, key.m_key); meta && meta->has_order) { it.has_order = true; @@ -2653,43 +2827,9 @@ namespace big::mod_settings } items.push_back(std::move(it)); } - else if (key.m_section.rfind(section_prefix, 0) == 0) + else if (place == 2) { - // An undescribed descendant contributes nothing: it neither shows as a row inside the group nor - // ranks the group, so a subtree of only undescribed keys produces no group row at all. A Chalk - // plain-string description counts as described too (entry_has_description). - if (!setting_is_described(stem, key.m_section, key.m_key) && !entry_has_description(entry.get())) - { - continue; - } - - // A descendant section: the direct child under `section` is the first path segment after the - // prefix. Collapse its whole subtree into one group row, ranked by its earliest-defined descendant. - const std::string rest = key.m_section.substr(section_prefix.size()); - const std::string child = rest.substr(0, rest.find('.')); - const std::string child_path = section_prefix + child; - const int app = get_setting_appearance_order(stem, key.m_section, key.m_key); - const auto git = groups.find(child_path); - if (git == groups.end()) - { - panel_item g; - g.is_group = true; - g.key = child; - g.child_section = child_path; - g.appearance = app; - // Defer the group's order to a real config child named "order" (see config_child_exists): with - // such a child, configDesc..order is that child's description, not the group's sort key. - if (const auto meta = resolved_metadata(stem, section, child); meta && meta->has_order && !config_child_exists(view_cfg, child_path, "order")) - { - g.has_order = true; - g.order = meta->order; - } - groups.emplace(child_path, std::move(g)); - } - else if (app < git->second.appearance) - { - git->second.appearance = app; - } + ensure_group(child_path, get_setting_appearance_order(stem, key.m_section, key.m_key)); } } } @@ -2699,24 +2839,47 @@ namespace big::mod_settings items.push_back(std::move(kv.second)); } - // Action buttons declared directly in this section (config.lua `action` entries). They carry no config value, - // so they are collected separately and sorted in with the settings by `order`. - for (auto& a : get_actions(stem, section)) + // Action buttons (config.lua `action` entries). Collected across ALL config sections (empty section = all) and + // bucketed by menu path, so an action moved with `group` lands on its target page like any setting. + for (auto& a : get_actions(stem, "")) { + std::string child_path; + const int place = placement(a.section, a.group, child_path); + if (place == 2) + { + ensure_group(child_path, get_setting_appearance_order(stem, a.section, a.key)); + continue; + } + if (place != 1) + { + continue; + } panel_item it; - it.is_action = true; - it.key = a.key; - it.has_order = a.has_order; - it.order = a.order; - it.action = std::move(a); + it.is_action = true; + it.key = a.key; + it.config_section = a.section; + it.has_order = a.has_order; + it.order = a.order; + it.action = std::move(a); items.push_back(std::move(it)); } // Virtual rows (config.lua `virtual = true` entries) - non-config rows whose value comes from Lua callbacks. - // They carry no config value either, so they are collected here and interleaved with the settings by `order` - // and config.lua source rank. A dynamic field on any of them makes an edit re-run this build (live refresh). - for (const auto& vr : get_virtual_rows(stem, section)) + // Collected across all sections and bucketed by menu path, interleaved with the settings by `order`/source + // rank. A dynamic field on a row that lands on THIS page makes an edit re-run this build (live refresh). + for (const auto& vr : get_virtual_rows(stem, "")) { + std::string child_path; + const int place = placement(vr.section, vr.group, child_path); + if (place == 2) + { + ensure_group(child_path, get_setting_appearance_order(stem, vr.section, vr.key)); + continue; + } + if (place != 1) + { + continue; + } if (vr.has_dynamic) { g_view_has_dynamic = true; @@ -2725,9 +2888,10 @@ namespace big::mod_settings it.is_virtual = true; it.virtual_interactive = vr.interactive; it.key = vr.key; + it.config_section = vr.section; it.has_order = vr.has_order; it.order = vr.order; - it.appearance = get_setting_appearance_order(stem, section, vr.key); + it.appearance = get_setting_appearance_order(stem, vr.section, vr.key); items.push_back(std::move(it)); } @@ -2851,7 +3015,10 @@ namespace big::mod_settings // declare it. if (it.is_virtual) { - const auto vmeta = resolved_metadata(stem, section, it.key); + // A `group` override can move a virtual row onto a page whose path differs from its config section, so + // all its Lua I/O (metadata/display/get) uses the row's real config section, not the view path. + const std::string& vsection = it.config_section; + const auto vmeta = resolved_metadata(stem, vsection, it.key); const std::string vname = vmeta ? resolve_localized(vmeta->name) : std::string{}; const std::string vlabel = escape_markup(!vname.empty() ? vname : key_to_display(it.key)); const std::string vdesc = vmeta ? resolve_localized(vmeta->description) : std::string{}; @@ -2868,6 +3035,7 @@ namespace big::mod_settings { PanelRow pr{row, RowKind::info, stem, it.key}; pr.disabled = true; + pr.config_section = vsection; pr.value_component = make_value_display(screen, escape_markup(value_text).c_str(), /*disabled*/ false); pr.description = vdesc; g_rows.push_back(std::move(pr)); @@ -2876,7 +3044,7 @@ namespace big::mod_settings if (!it.virtual_interactive) { - build_readonly(get_virtual_display(stem, section, it.key)); + build_readonly(get_virtual_display(stem, vsection, it.key)); continue; } @@ -2884,7 +3052,7 @@ namespace big::mod_settings // setting is inferred from its config value type. When get() returns nil at build time (the mod's state // is not ready yet), the author can force the widget with `type` - synthesize a starting value from // `default` (or a sensible fallback) so the widget still builds instead of falling back to read-only. - virtual_value vv = get_virtual_value(stem, section, it.key); + virtual_value vv = get_virtual_value(stem, vsection, it.key); if (vv.type == virtual_value::kind::none && vmeta && vmeta->type != widget_type::inferred) { const std::string& dflt = vmeta->default_value; // empty when no default declared @@ -2990,6 +3158,7 @@ namespace big::mod_settings { PanelRow pr{ro_row, RowKind::setting, stem, it.key}; pr.disabled = true; + pr.config_section = vsection; pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); pr.description = context_blocked ? @@ -3044,6 +3213,7 @@ namespace big::mod_settings PanelRow pr{row, RowKind::setting, stem, it.key}; pr.disabled = disabled; pr.is_virtual_input = true; + pr.config_section = vsection; // real config section for runtime get/set (may differ from view path) pr.value_component = value; pr.description = vdesc; if (is_enum) @@ -3072,46 +3242,58 @@ namespace big::mod_settings continue; } - // A nested group drills into its child section when clicked/activated. + // A nested group drills into its child menu path when clicked/activated. A config-derived group takes its + // display name/description from its configDesc entry (its menu path equals its config section); an author + // group (configDesc `groups`) carries its own name/description captured during collection. if (it.is_group) { - auto gmeta = resolved_metadata(stem, section, it.key); - - // A group's desc table doubles as its children's descriptions, so a group-consumed field (displayName/ - // description/hidden) that is actually one of the group's own config children belongs to that child, - // not the group. Defer to the child so the two never collide (the child renders it normally; the group - // falls back to its default for that field). `order` is deferred the same way during collection above. - if (gmeta && view_cfg) + std::string glabel; + std::string gdescription; + if (it.is_author_group) { - if (config_child_exists(view_cfg, it.child_section, "displayName")) - { - gmeta->name.clear(); - } - if (config_child_exists(view_cfg, it.child_section, "description")) + const std::string gname = resolve_localized(it.author_name); + glabel = escape_markup(!gname.empty() ? gname : key_to_display(it.key)); + gdescription = resolve_localized(it.author_description); + } + else + { + auto gmeta = resolved_metadata(stem, section, it.key); + + // A group's desc table doubles as its children's descriptions, so a group-consumed field + // (displayName/description/hidden) that is actually one of the group's own config children belongs + // to that child, not the group. Defer to the child so the two never collide. `order` is deferred + // the same way during collection above. + if (gmeta && view_cfg) { - gmeta->description.clear(); + if (config_child_exists(view_cfg, it.child_section, "displayName")) + { + gmeta->name.clear(); + } + if (config_child_exists(view_cfg, it.child_section, "description")) + { + gmeta->description.clear(); + } + if (config_child_exists(view_cfg, it.child_section, "hidden")) + { + gmeta->hidden = false; + } } - if (config_child_exists(view_cfg, it.child_section, "hidden")) + + if (gmeta && gmeta->hidden) { - gmeta->hidden = false; + continue; } + const std::string gname = gmeta ? resolve_localized(gmeta->name) : std::string{}; + glabel = escape_markup(!gname.empty() ? gname : key_to_display(it.key)); + gdescription = gmeta ? resolve_localized(gmeta->description) : std::string{}; } - if (gmeta && gmeta->hidden) - { - continue; - } - const std::string gname = gmeta ? resolve_localized(gmeta->name) : std::string{}; - const std::string glabel = escape_markup(!gname.empty() ? gname : key_to_display(it.key)); if (auto* row = make_text_row(screen, glabel.c_str(), disabled)) { PanelRow pr{row, RowKind::group, stem, {}}; pr.disabled = disabled; pr.target_section = it.child_section; - if (gmeta) - { - pr.description = resolve_localized(gmeta->description); - } + pr.description = gdescription; g_rows.push_back(std::move(pr)); } continue; diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index fc8811d..5f0c3da 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -46,6 +46,26 @@ namespace big::mod_settings enumeration, }; + // An author-declared menu group (configDesc `groups`): a category in the in-game menu that does NOT correspond to a + // config section. It lets a mod present a flat (or differently nested) config under an arbitrary menu tree, by + // moving entries into these groups with a per-entry `group`. `id` is the identity used in a `group` path (the table + // key in configDesc.groups); name/description are shown in the menu (resolved to the current language); order sorts + // it among its siblings (else first-declared order); children are nested sub-groups. + struct menu_group + { + std::string id; + localized_text name; + localized_text description; + bool has_order = false; + double order = 0.0; + std::vector children; + }; + + // The author-declared menu group tree (configDesc `groups`) for mod `guid`, empty when none was declared. The + // settings menu uses it for the display name/order/description of groups a per-entry `group` references but that + // do not exist as config sections. Populated fresh each Lua-state init by rom.mod_settings.load. + std::vector mod_menu_groups(const std::string& guid); + // Author-declared metadata for a single setting, extracted from its config.lua description table by // rom.mod_settings.load. Consulted by the settings menu. Only settings whose description is a rich table have an // entry. The rest fall back to type-based rendering. Every field is an author-only input that cannot be inferred @@ -105,6 +125,12 @@ namespace big::mod_settings widget_type type = widget_type::inferred; bool has_default = false; std::string default_value; + + // Menu placement override (configDesc `group`): the author-declared menu path this entry appears under instead + // of its config-section default. Empty -> placed by its config section. Each segment is a config child section + // or an author group declared in configDesc `groups` (see menu_group). Applies to settings, actions and + // virtual rows alike. + std::vector group; }; // True if a mod author declared this setting as requiring a game restart to take effect (via `restart_required = @@ -149,6 +175,7 @@ namespace big::mod_settings editable_context context = editable_context::any; // when the button is enabled (main-menu vs in-save) bool disabled = false; // greyed and non-interactive (author-declared, may be dynamic) bool has_dynamic = false; // name/description/order/disabled is a Lua function + std::vector group; // menu placement override (configDesc `group`), empty -> config section }; // The action buttons declared directly in config `section` of mod `guid` (not recursing into child sections). @@ -172,6 +199,7 @@ namespace big::mod_settings double order = 0.0; bool has_dynamic = false; // a name/description/values/min/max/text field is a Lua function (re-resolve at render) bool interactive = false; // has a `set` callback (an editable get/set row) rather than a read-only `text` row + std::vector group; // menu placement override (configDesc `group`), empty -> config section }; // The virtual (non-config) rows declared directly in config `section` of mod `guid` (not recursing into child From 0d6e6277e897041764b17b081ca302e6c3271ce5 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:07:52 +0100 Subject: [PATCH 065/100] Small bug fixes --- src/hades2/mod_settings/config_api.cpp | 220 ++-- src/hades2/mod_settings/mod_settings.cpp | 1161 +++++++++------------- src/hades2/mod_settings/mod_settings.hpp | 8 +- 3 files changed, 561 insertions(+), 828 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 11708f2..e1d07e5 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -29,33 +29,27 @@ using namespace al; namespace big::mod_settings { - // Author-declared per-setting metadata registry, populated from each mod's config.lua by rom.mod_settings.load. - // Keyed by guid + '\0' + section + '\0' + key. Holds the display-name override, numeric bounds, enum options, - // ordering, and the restart-required flag that the settings menu reads to pick and drive a widget. Only settings - // whose config.lua description is a rich table are registered. The rest fall back to type-based rendering. + // Author-declared per-setting metadata (display name, bounds, enum options, ordering, restart flag), populated from + // each mod's config.lua by rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Only settings with a + // rich-table description are registered - the rest fall back to type-based rendering. static std::mutex g_metadata_mutex; static std::map g_setting_metadata; - // Per-setting appearance order (rank of a key's definition in config.lua), populated for EVERY bound key (not just - // those with rich metadata). Keyed the same way as g_setting_metadata. The menu uses it to order rows that have no - // author-declared `order` in their config-file source order, because Lua pairs() and the alphabetical config map - // both lose the config.lua order. + // Per-setting appearance rank (a key's definition order in config.lua), for EVERY bound key. Keyed like + // g_setting_metadata. Orders rows that have no author `order`, since Lua pairs() and the config map both lose the + // source order. static std::map g_appearance_order; - // Serialized config.lua default for every bound key (whether or not it has a rich metadata table), captured at - // load. The settings menu's Reset action restores a setting to this value. Keyed the same way as g_setting_metadata - // (guid + '\0' + section + '\0' + key). + // Serialized config.lua default for every bound key, captured at load. The menu's Reset action restores this value. + // Keyed like g_setting_metadata. static std::map g_setting_default; - // (section, key) pairs that carry a configDesc entry (a description string or a rich table). A config key with no - // configDesc entry is not shown in the menu, except the mod's master "enabled" toggle (always shown so the mod - // stays toggleable). Keyed the same way as g_setting_metadata (guid + '\0' + section + '\0' + key). + // (section, key) pairs that carry a configDesc entry. A key with none is hidden from the menu, except the mod's + // master "enabled" toggle (always shown). Keyed like g_setting_metadata. static std::set g_described_keys; - // Guids of mods that called rom.mod_settings.opt_out(), i.e. asked not to be configured through the in-game menu, - // mapped to the optional custom description the mod passed (empty when none was given). Guarded by - // g_metadata_mutex. Cleared and rebuilt on each Lua-state init (see bind_config_api) because opt_out re-runs with - // each mod's main.lua. + // Guids of mods that called rom.mod_settings.opt_out(), mapped to the optional custom description they passed + // (empty when none). Cleared and rebuilt on each Lua-state init because opt_out re-runs with each mod's main.lua. static std::map g_opted_out_mods; // Action buttons declared in config.lua (configDesc entries with an `action` function, no config value). Keyed by @@ -131,7 +125,7 @@ namespace big::mod_settings } // True if (section, key) carries a configDesc entry (any form: a description string, a setting/action table, or a - // group table). The menu shows only described keys; an undescribed config key is hidden (see build_mod_settings). + // group table). The menu shows only described keys. An undescribed config key is hidden (see build_mod_settings). bool setting_is_described(const std::string& guid, const std::string& section, const std::string& key) { std::scoped_lock lock(g_metadata_mutex); @@ -176,10 +170,8 @@ namespace big::mod_settings return it != g_menu_groups.end() ? it->second : std::vector{}; } - // Finds the byte offset of a key's definition (" =") in config.lua source, whole-word and not "==", or npos. - // The first match is the key's place in the returned `config` defaults table (defined before configDesc), which is - // the author's intended display order. Occurrences inside strings/prose don't match because they are not followed - // by a bare '='. + // Byte offset of a key's definition (" =") in config.lua source (whole-word, not "=="), or npos. The first + // match is the key's place in the `config` defaults table (before configDesc), the author's intended display order. static std::size_t find_key_definition(const std::string& src, const std::string& key) { auto is_ident = [](char c) @@ -212,10 +204,8 @@ namespace big::mod_settings static std::string serialize_option(const sol::object& v); // defined below. - // Parses a user-facing string field that is either a plain scalar or a localization table (keyed by the game's - // language folder codes, e.g. { en = "...", ["zh-TW"] = "..." }). A scalar is stored under the empty key. A table - // contributes one entry per string-keyed string value. An empty or absent value yields an empty map (i.e. no - // override). + // Parses a user-facing string field: either a plain scalar (stored under the empty key) or a localization table + // keyed by language folder codes, e.g. { en = "...", ["zh-TW"] = "..." }. Empty/absent yields an empty map. static localized_text parse_localized(const sol::object& o) { localized_text out; @@ -307,13 +297,22 @@ namespace big::mod_settings } } } + // '.' is the menu-path separator, so a segment carrying one would silently mis-nest. Reject the whole override + // (the row keeps its config-section placement) and tell the author to use an array of segments to nest. + for (const auto& seg : out) + { + if (seg.find('.') != std::string::npos) + { + LOG(WARNING) << "[mod_settings] ignoring `group` override: segment '" << seg << "' contains '.', which is reserved as the menu-path separator (use an array of segments to nest)."; + return {}; + } + } return out; } - // Recursively parses a configDesc `groups` table into menu_group nodes. Each key is a group's identity (used in a - // `group` path); its value is a metadata table carrying an optional localized `displayName`/`description`, a numeric - // `order`, and a nested `groups` table of sub-groups. Non-table values are skipped. Order among siblings: explicit - // `order` first (ascending), then declaration order is unspecified in Lua, so callers fall back to the id. + // Recursively parses a configDesc `groups` table into menu_group nodes. Each key is a group id (used in a `group` + // path), its value a table of optional `displayName`/`description`, `order`, and nested `groups`. Non-tables are + // skipped. Siblings sort by `order` then id (Lua declaration order is lost). static std::vector parse_menu_groups(const sol::object& groups_obj) { std::vector out; @@ -331,6 +330,11 @@ namespace big::mod_settings sol::table gt = v.as(); menu_group g; g.id = k.as(); + if (g.id.find('.') != std::string::npos) + { + LOG(WARNING) << "[mod_settings] ignoring menu group id '" << g.id << "' containing '.', which is reserved as the menu-path separator (nest via a `groups` sub-table instead)."; + return; + } g.name = parse_localized(gt["displayName"]); g.description = parse_localized(gt["description"]); if (sol::object order = gt["order"]; order.get_type() == sol::type::number) @@ -411,10 +415,9 @@ namespace big::mod_settings } } - // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. Missing fields keep - // their defaults. The widget kind is not stored: the menu. Derives it from the config value's type plus the - // presence of `values` (enum), so authors never declare a `type`. Author-only inputs that cannot be inferred (name, - // bounds, enum options/labels, order, hidden, restart) are what this captures. + // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. Captures the + // author-only inputs that can't be inferred (name, bounds, enum options/labels, order, hidden, restart). The widget + // kind is not stored - the menu derives it from the value's type plus the presence of `values` (enum). static setting_metadata extract_metadata(const sol::table& desc) { setting_metadata m; @@ -537,16 +540,13 @@ namespace big::mod_settings } // When the setting may be changed relative to a loaded save (`editableContext`). The menu forces the master - // "enabled" toggle and restartRequired settings to main_menu regardless, so authors need only annotate the - // in-between cases. + // "enabled" toggle and restartRequired settings to main_menu regardless. m.context = parse_editable_context(desc["editableContext"], editable_context::any); - // A field written as a Lua function is a dynamic field: It is skipped by the type-guarded reads above (a - // function is not a number/table/bool/string) and instead re-evaluated at render time by - // resolve_setting_metadata. Record that any such field is present so the menu knows to resolve. `hidden` is - // intentionally NOT dynamic: showing/hiding a row shifts the layout and the row set is only re-evaluated on a - // full rebuild, so a live-changing condition must use `disabled` instead. `editableContext` is a fixed design - // property of a setting, so it is static too. + // A field written as a Lua function is dynamic: skipped by the type-guarded reads above and re-evaluated at + // render by resolve_setting_metadata. Record that any is present so the menu knows to resolve. `hidden` is + // intentionally NOT dynamic (toggling it shifts layout, only re-done on a full rebuild - use `disabled` for a + // live condition), and `editableContext` is a fixed design property, so both stay static. for (const char* field : {"displayName", "description", "disabledDescription", "min", "max", "step", "values", "labels", "order", "disabled"}) { if (desc[field].get_type() == sol::type::function) @@ -563,9 +563,8 @@ namespace big::mod_settings } // The Lua-side registry (rom.mod_settings._descs) mapping guid -> the mod's raw configDesc table, kept alive so - // dynamic (function) description fields and action callbacks can be evaluated at render time. Lua-owned and - // recreated with the rom.mod_settings table each Lua state, so it never dangles. Returns a nil object if the guid - // has no stored description. + // dynamic description fields and action callbacks can be evaluated at render. Recreated each Lua state, so it never + // dangles. Returns a nil object if the guid has no stored description. static sol::object stored_descriptions(sol::state_view state, const std::string& guid) { sol::object ns = state[rom::g_lua_api_namespace]; @@ -643,10 +642,9 @@ namespace big::mod_settings return rv.get(); } - // Builds a shallow copy of a setting's description table with every dynamic (function) field replaced by its - // evaluated value, so the existing extract_metadata can read it as if the author had written static values. - // Callables invoked on their own events (not read as metadata) are intentionally left as-is: `onChange`, `action`, - // and a virtual row's `get`/`set`/`text` (get/text are called by get_virtual_display; set takes an argument). + // Shallow-copies a description table with every dynamic (function) field replaced by its evaluated value, so + // extract_metadata can read it as static. Event callables are left as-is: `onChange`, `action`, and a virtual row's + // `get`/`set`/`text`. static sol::table resolve_description(sol::state_view state, const sol::table& desc, const std::string& guid) { sol::table out = state.create_table(); @@ -687,10 +685,9 @@ namespace big::mod_settings a.group = parse_group(entry["group"]); } - // Walks a mod's configDesc (guided by the config defaults structure, like bind_defaults) collecting action buttons: - // description entries carrying an `action` function, which have no config value. Recurses into config groups so - // actions can live at any drilldown level. Static fields are captured now dynamic ones (has_dynamic) are - // re-resolved at render by get_actions. + // Walks a mod's configDesc (guided by the config defaults, like bind_defaults) collecting action buttons - + // description entries carrying an `action` function and no config value. Recurses into groups so actions can live at + // any level. Dynamic fields (has_dynamic) are re-resolved at render by get_actions. static void collect_actions(const sol::table& config_tbl, const sol::object& desc_obj, const std::string& section, std::vector& out) { if (desc_obj.is()) @@ -735,10 +732,8 @@ namespace big::mod_settings } } - // configDesc field names that are metadata OF a setting/group/action/virtual row, not child keys. When walking a - // desc table for child rows (virtual detection + orphan validation), these are skipped so a group's OWN - // displayName/description/order/... are not mistaken for missing config keys (a group desc table mixes the group's - // metadata with its child descriptions). + // configDesc field names that are metadata OF an entry, not child keys. Skipped when walking a desc table for child + // rows so a group's own displayName/description/... are not mistaken for missing config keys. static bool is_reserved_desc_field(const std::string& key) { static const std::set reserved = { @@ -772,12 +767,10 @@ namespace big::mod_settings return reserved.contains(key); } - // Walks a mod's configDesc (guided by the config structure, like collect_actions) collecting virtual rows and - // validating every entry. A configDesc entry must resolve to one of: a config value (a config-backed setting or a - // group), an `action` function, or an explicit `virtual = true` marker. An entry that is NONE of these is almost - // always an author mistake (they described a key but forgot to add it to `config`), so it is logged. A `virtual` - // row with no `get`/`text` (nothing to display) is logged too. Recurses into config groups only, like the actions - // and defaults walks, so virtual rows live alongside config rows in a config-backed section. + // Walks a mod's configDesc (like collect_actions) collecting virtual rows and validating every entry. An entry must + // resolve to a config value (setting or group), an `action`, or an explicit `virtual = true` - anything else is + // logged as a likely author mistake (a described key missing from `config`). A `virtual` row with no `get`/`text` + // is logged too. static void collect_virtual_rows(const std::string& guid, const sol::table& config_tbl, const sol::object& desc_obj, const std::string& section, std::vector& out) { if (desc_obj.is()) @@ -957,16 +950,11 @@ namespace big::mod_settings } } - // Attaches a Lua onChange callback (from a setting's config.lua description) to its config entry. toml_v2 already - // fires config_entry::m_setting_changed after a value changes and the file is saved. This routes that to Lua, - // passing the new value and the setting key. It fires only for an edit made through the in-game options menu - // (on_change_callbacks_enabled gates on the options screen being open in-game), so it is never called in the main - // menu - where there is no live run to apply to and Lua game-data edits are discarded when a save loads - nor from - // a mod's own config write outside the menu. A same-value write is a no-op and does not fire, so a callback that - // writes back cannot loop. It is stored on the entry, which is owned by the mod's config_file - // (module->m_data.m_config_files) and destroyed with the Lua state on App::Reset - so the captured sol reference - // shares the mod's lifecycle and never dangles (unlike a C++ static). Called protected: a Lua error is logged, - // never propagated. + // Routes toml_v2's config_entry::m_setting_changed (fired after a value changes and the file is saved) to a Lua + // onChange callback, passing the new value and the key. Fires only for edits made through the in-game options menu + // (gated by on_change_callbacks_enabled), never in the main menu or from a mod's own writes. A same-value write is a + // no-op, so a callback that writes back cannot loop. Stored on the entry (owned by the mod's config_file, destroyed + // with the Lua state on App::Reset), so the captured sol reference never dangles. Called protected. static void attach_on_change(toml_v2::config_file::config_entry_base* entry, sol::protected_function callback) { if (!entry || !callback.valid()) @@ -1011,9 +999,8 @@ namespace big::mod_settings return value > 0; } - // Registry keys for the table-based config proxy machinery: one shared metatable, plus two weak-keyed maps from - // each wrapper table to the config_file and section it points at. Stored in the Lua registry so the free - // metamethods can recover them per call. + // Registry keys for the config proxy: one shared metatable, plus two weak-keyed maps from each wrapper table to the + // config_file and section it points at, so the metamethods can recover them per call. static constexpr const char* k_proxy_metatable = "h2m_mod_config_metatable"; static constexpr const char* k_proxy_cf_map = "h2m_mod_config_cf"; static constexpr const char* k_proxy_section_map = "h2m_mod_config_section"; @@ -1022,11 +1009,9 @@ namespace big::mod_settings // SGG_Modding-Chalk). Defined after mod_config_proxy, but the struct's child accessors call it, so forward-declare. static sol::object make_proxy(sol::this_state ts, toml_v2::config_file* cf, const std::string& section); - // Live read/write view over a config_file section, returned to the mod as its `config` object. Reads/writes go - // straight through to the underlying config entries (so the in-game menu and the mod always see the same values). - // Nested sections resolve to child proxies. It holds a raw config_file pointer (not a sol reference): the - // config_file is owned by the mod and both it and this proxy are recreated together per Lua state, so nothing - // dangles across an App::Reset. + // Live read/write view over a config_file section, returned to the mod as its `config`. Reads/writes go straight + // through to the underlying entries (so the menu and the mod see the same values), nested sections resolve to child + // proxies. Holds a raw config_file pointer owned by the mod and recreated with it per Lua state, so nothing dangles. struct mod_config_proxy { toml_v2::config_file* cf = nullptr; @@ -1054,10 +1039,9 @@ namespace big::mod_settings return; } - // Assigning a whole table to a nested section (e.g. config.group = { a = 1, b = 2 }, or a preset order to - // config.biome_pool.custom_order_data) sets each matching leaf in that child section, recursing for deeper - // tables. Only existing bound leaves are written. String keys with no entry are ignored, mirroring bind's - // string-key-only binding. + // Assigning a whole table to a nested section (e.g. config.group = { a = 1, b = 2 }) sets each matching leaf + // in that child section, recursing for deeper tables. Only existing bound leaves are written - string keys + // with no entry are ignored. const std::string child = section + "." + key; if (value.is() && has_section(cf, child)) { @@ -1072,9 +1056,8 @@ namespace big::mod_settings } } - // Snapshots this section's immediate children into a fresh Lua table: each leaf key maps to its current value - // and each direct sub-section name maps to a child proxy. The iteration metamethods hand this plain table to - // Lua's own pairs/next so consumers walk the live config exactly like a normal table (mirrors Chalk's wrapper). + // Snapshots this section's immediate children into a fresh Lua table (each leaf key to its value, each + // sub-section to a child proxy) so the iteration metamethods can hand it to Lua's pairs/next. sol::table children_snapshot(sol::this_state ts) const { sol::state_view lua(ts); @@ -1269,12 +1252,10 @@ namespace big::mod_settings setting_metadata meta; }; - // Recursively binds a config.lua `defaults` table into `cf` under `section`, forwarding each leaf's description. - // Nested tables become sub-sections ("section.key"). Each flat leaf whose description is a rich table has its - // metadata extracted into `meta_out` (keyed by section+key), and every leaf that carries any configDesc entry (a - // string or a table) is recorded in `described_out` so the menu can hide undescribed keys. config_file::bind - // adopts a value already saved in the .cfg, preserving user edits, and binds under section "config", so the .cfg - // stays byte-compatible with what SGG_Modding-Chalk wrote. + // Recursively binds a config.lua `defaults` table into `cf` under `section` (nested tables become sub-sections). + // A leaf with a rich-table description has its metadata extracted into `meta_out`, and any leaf with a configDesc + // entry is recorded in `described_out` so the menu can hide undescribed keys. bind adopts a value already in the + // .cfg (preserving user edits) under section "config", keeping it byte-compatible with SGG_Modding-Chalk. static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector& meta_out, std::vector>& defaults_out, std::vector>& described_out) { sol::table desc_tbl; @@ -1328,7 +1309,7 @@ namespace big::mod_settings { defaults_out.emplace_back(section, key, toml_v2::toml_type_converter::convert_to_string(*default_any)); - // Record a described leaf so the menu shows it; an undescribed leaf is hidden. Only leaves reach here + // Record a described leaf so the menu shows it. An undescribed leaf is hidden. Only leaves reach here // (default_any is set for bool/number/string, not a group table). if (described) { @@ -1359,14 +1340,12 @@ namespace big::mod_settings // Lua API: Function. Table: mod_settings. Name: load. Param: config_lua: string: Path, relative to the mod's // folder, of the config.lua that returns `config, configDesc`. Returns: table: A live read/write proxy over the - // mod's config, index it to read a setting and assign to write one. Loads a mod's config.lua and registers its - // settings under the Mods tab of the in-game Options menu, returning a live read/write proxy over the config. When - // using this, you do not need to depend on `Chalk`. + // mod's config - index it to read a setting, assign to write one. Registers the mod's settings under the Mods tab + // of the Options menu. Replaces depending on `Chalk`. static sol::object load(sol::this_state ts, sol::this_environment this_env, const std::string& config_lua) { - // Uses the calling mod (this_environment) to derive its /.cfg path and create a native - // config_file owned by that mod, loads the mod's config.lua, binds its defaults and descriptions into that - // config_file, records any restart-required settings, and returns the proxy. + // Derives the mod's /.cfg, creates a config_file owned by the mod, runs its config.lua, binds the + // defaults/descriptions, records restart-required settings, and returns the proxy. if (!this_env) { return sol::lua_nil; @@ -1485,6 +1464,12 @@ namespace big::mod_settings const std::size_t off = source_text.empty() ? std::string::npos : find_key_definition(source_text, vr.key); by_offset.emplace_back(off, vr.section, vr.key); } + // Same for actions, so an un-ordered action button interleaves with the rows around its configDesc definition. + for (const auto& a : actions) + { + const std::size_t off = source_text.empty() ? std::string::npos : find_key_definition(source_text, a.key); + by_offset.emplace_back(off, a.section, a.key); + } std::stable_sort(by_offset.begin(), by_offset.end(), [](const auto& a, const auto& b) @@ -1561,10 +1546,9 @@ namespace big::mod_settings return m; } - // True when the game is in the hub (the Crossroads): the game Lua global `CurrentHubRoom` is non-nil (the game sets - // it to the current hub room while in the hub and clears it during a run). Reads the game's Lua state directly (the - // same state mods run in, where `_G` is the game globals - see hades_lua.hpp), so it must be called on the game - // thread while the state is alive. Returns false when the Lua manager is not up yet. + // True when the game is in the hub (the Crossroads), i.e. the game Lua global `CurrentHubRoom` is non-nil. Reads + // the game's Lua state directly, so it must be called on the game thread while the state is alive. Returns false + // when the Lua manager is not up yet. bool game_is_in_hub() { if (!big::g_lua_manager) @@ -1680,7 +1664,7 @@ namespace big::mod_settings // The read-only display comes from `text`: a plain string, or a function returning a bool/number/string that // is stringified. Evaluated protected so a mod error cannot crash the menu. (`get`/`set` is the separate - // editable value pair, added with interactive virtual rows; it is not a display path.) + // editable value pair, added with interactive virtual rows - it is not a display path.) const sol::object text = t["text"]; if (text.get_type() == sol::type::string) { @@ -1902,10 +1886,9 @@ namespace big::mod_settings } // Lua API: Function. Table: mod_settings. Name: opt_out. Param: description: string: Optional. A plain string or a - // localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note when the mod's greyed - // row is highlighted. Excludes the calling mod from the in-game mod settings menu: it stays listed but greyed out - // and cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod should - // not be edited in-game. Works with Chalk or rom.mod_settings.load. + // localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note. Excludes the calling + // mod from the in-game menu: it stays listed but greyed out and cannot be opened. Works with Chalk or + // rom.mod_settings.load. static void opt_out(sol::this_environment this_env, sol::object description) { // Keyed by the calling mod's guid (which matches its config-file stem), so the menu can grey the matching row @@ -1931,9 +1914,8 @@ namespace big::mod_settings void bind_config_api(sol::state_view& state, sol::table& lua_ext) { // A fresh Lua state re-runs every mod's main.lua, so drop all per-mod registries before those calls re-register - // them load() also clears its own guid, but a mod uninstalled since the last state would never call load again, - // so its stale entries would otherwise linger forever. The opt-out set has no load() to hang a per-guid clear - // off either. Clearing everything here keeps all four registries bounded to the currently-loaded mods. + // them. A mod uninstalled since the last state never calls load again, so clearing everything here keeps the + // registries bounded to the currently-loaded mods. { std::scoped_lock lock(g_metadata_mutex); g_setting_metadata.clear(); @@ -1946,12 +1928,10 @@ namespace big::mod_settings g_described_keys.clear(); } - // The config object handed to mods is a plain Lua table (so `type(config) == "table"`, matching - // SGG_Modding-Chalk), driven by one shared metatable. It reproduces Chalk's full metamethod surface so mods - // migrating off Chalk keep working: index/new_index read/write entries, and len/pairs/ipairs (plus ModUtil's - // next/inext, which it reads via rawget(getmetatable(t), '__next'/'__inext')) make the config iterable like a - // normal table. Each wrapper table's (cf, section) live in weak-keyed registry maps, so the wrapper stays empty - // (nothing leaks into rawpairs) and is collected with it. + // The config object handed to mods is a plain Lua table (so `type(config) == "table"`, matching Chalk), driven + // by one shared metatable that reproduces Chalk's metamethod surface: index/new_index read/write entries, and + // len/pairs/ipairs (plus ModUtil's next/inext via rawget(getmetatable(t), '__next'/'__inext')) make it iterable. + // Each wrapper's (cf, section) live in weak-keyed registry maps, so the wrapper stays empty and is collected with it. sol::table proxy_metatable = state.create_table(); proxy_metatable["__index"] = &proxy_index; proxy_metatable["__newindex"] = &proxy_new_index; diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index dc4da07..e027783 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -33,14 +33,9 @@ namespace big::mod_settings using sgg::MiscSettingsScreen; using sgg::Vec2; - // Hades II's in-game options menu is the native C++ screen sgg::MiscSettingsScreen. Its category tabs include - // several non-user categories (Editor, Debug, ...) that are created but hidden. The "Editor" one is reused as the - // "Mods" tab. Option rows are native GUIComponentButtons built here. A freshly constructed component is invisible - // because it has no visual data. MenuScreen::ApplyDataToComponent applies the screen's SJSON template whose name - // matches the component's mName, which is what makes it render. So each row is named after an existing template - // ("CategoryOptionsButton"), has that template applied, is given its label, and is linked into mComponents (drawn) - // and mOptions (freed/unlinked on category switch). GUIComponent::mName lives at this offset. It is an - // eastl::string used by ApplyDataToComponent to look up the matching template. + // Hades II's in-game options menu is the native C++ screen sgg::MiscSettingsScreen. Option rows are native + // GUIComponentButtons built here. GUIComponent::mName lives at this offset. It is an eastl::string used by + // ApplyDataToComponent to look up the matching template. static constexpr std::size_t gui_component_name_offset = 0x4'88; // Each GUIComponent embeds an sgg::ComponentData (mData) whose mDef (sgg::ComponentDataDef) drives its @@ -65,7 +60,7 @@ namespace big::mod_settings static constexpr std::size_t def_selected_graphic = 0x84; // mSelectedGraphic (HashGuid) static constexpr std::size_t def_alternate_graphic = 0x88; // mAlternateGraphic (HashGuid) // SoundCue def fields (each sgg::SoundCue is 0x10 bytes: pOwner @0, mName HashGuid id @8). The base OnClicked plays - // mPressSound; the native toggle handler ToggleOptionValueChanged (which our C++ toggle path replaces) is what + // mPressSound. The native toggle handler ToggleOptionValueChanged (which our C++ toggle path replaces) is what // plays mToggleOnSound/mToggleOffSound, so we copy the matching one into mPressSound to reproduce the sound. static constexpr std::size_t def_press_sound = 0x1'B0; // mPressSound (sgg::SoundCue) static constexpr std::size_t def_toggle_on_sound = 0x1'E0; // mToggleOnSound (sgg::SoundCue) @@ -85,10 +80,8 @@ namespace big::mod_settings static constexpr std::size_t def_spacing = 0x1'5C; // mSpacing (float) row pitch, read by UpdateScrollState static constexpr std::size_t def_fade_speed = 0x2'1C; // mFadeSpeed (float) opacity ease rate (component +0x2C4) - // Opacity ease rate applied to every row so all row types fade at one uniform speed. The native OptionToggleButton - ///OptionNumBox templates use 10.0. CategoryOptionsButton (our text/value/ group rows) declares none, so we set it - // explicitly. GUIComponent::Update moves mFadeOpacity toward mFadeTarget by dt * mFadeSpeed each frame, so this - // drives the fade timing. + // Opacity ease rate applied to every row so all row types fade at one uniform speed. GUIComponent::Update moves + // mFadeOpacity toward mFadeTarget by dt * mFadeSpeed each frame, so this drives the fade timing. static constexpr float row_fade_speed = 10.0f; // Native sgg::MessageDialog (the single-button message box the game shows in the MAIN MENU for save/file. Errors, @@ -103,19 +96,16 @@ namespace big::mod_settings static constexpr std::size_t dialog_confirm_button_offset = 0x1'A0; // sgg::MenuScreen::mConfirmButton static constexpr std::size_t dialog_message_offset = 0x2'B0; // sgg::MessageDialog::mMessageText - // The MessageDialog.sjson MessageText template renders at FontSize 26, which is larger than we want for the - // multi-line body. The rendered size is driven by GUIComponentTextBox::mFontHandle (@0x6A4). Scaling its - // mFontSizeRatio (@+0x0C)/mEnglishFontSizeRatio (@+0x10) shrinks it. The def's mFontSize is ignored once the - // sjson template is loaded, so we scale the live handle. + // The MessageDialog.sjson MessageText template renders at FontSize 26, which is larger than we want for the multi-line + // body. The rendered size is driven by GUIComponentTextBox::mFontHandle (@0x6A4). Scaling its mFontSizeRatio + // (@+0x0C)/mEnglishFontSizeRatio (@+0x10) shrinks it. static constexpr std::size_t textbox_font_handle_offset = 0x6'A4; // GUIComponentTextBox::mFontHandle static constexpr std::size_t font_handle_size_ratio_offset = 0x0C; // sgg::FontHandle::mFontSizeRatio static constexpr std::size_t font_handle_eng_size_ratio_offset = 0x10; // sgg::FontHandle::mEnglishFontSizeRatio static constexpr float restart_message_font_scale = 0.75f; // ~26 -> ~19.5 - // Module-relative RVAs (current Ship build) for the overloaded functions that cannot be picked by name from the PDB - // symbol map. Resolved at runtime relative to the button-ctor anchor: anchor_runtime - anchor_rva + target_rva. - // AddScreen has three overloads. The 4-arg one inserts at the END of the screen list (drawn on top), unlike the - // 2-arg one which front-inserts (drawn under the full-screen options menu = invisible). + // Module-relative RVAs for overloaded functions the PDB map cannot disambiguate. Resolved from + // anchor_runtime - anchor_rva + target_rva. AddScreen's 4-arg overload appends so the dialog draws on top. static constexpr std::uintptr_t anchor_rva = 0x11'5C'70; // GUIComponentButton::GUIComponentButton static constexpr std::uintptr_t message_dialog_ctor_rva = 0x16'EE'60; // sgg::MessageDialog::MessageDialog static constexpr std::uintptr_t add_screen_rva = 0x14'7D'D0; // sgg::ScreenManager::AddScreen @@ -134,9 +124,9 @@ namespace big::mod_settings static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; // The config/control GLOBALS below (ConfigOptions::UseMouse/ConfigOptions::Language/Controls::Cancel/ - // Controls::Select) used to be addressed by RVA off the anchor too, but they live in .data/.rdata, which a game - // update can grow and shift independently of .text, so an anchor-relative RVA cannot be trusted for them. They - // are named PDB globals, so they are now resolved by name (update-proof) - see set_up_hooks. + // Controls::Select) used to be addressed by RVA off the anchor too, but they live in .data/.rdata, which a game update + // can grow and shift independently of .text, so an anchor-relative RVA cannot be trusted for them. They are named PDB + // globals, so they are now resolved by name (update-proof) - see set_up_hooks. // sgg::GUIComponentNumBox field offsets (DIA-validated on the current Ship build) sizeof 0x5D0. Derives directly // from GUIComponent (not GUIComponentButton). @@ -153,10 +143,8 @@ namespace big::mod_settings static constexpr std::size_t numbox_label_text_offset = 0x5'A8; // mTextBox (GUIComponentTextBox*, the label) static constexpr std::size_t numbox_sizeof = 0x5'D0; - // sgg::GUIComponentButton box-graphic scaling. The box ("Button_Secondary") is a single-frame animation reached - // via mAnim. GUIComponentButton::Draw pushes only a uniform scale into it, so a non-uniform (wider) box needs the - // anim's own def mScaleX plus mScaleModifierOnlyX, which the anim draw path honours. Offsets DIA-validated on the - // current Ship build (button-box-width RE). + // sgg::GUIComponentButton box-graphic scaling. GUIComponentButton::Draw pushes only a uniform scale into it, so a + // non-uniform (wider) box needs the anim's own def mScaleX plus mScaleModifierOnlyX, which the anim draw path honours. static constexpr std::size_t button_anim_offset = 0x5'70; // GUIComponentButton::mAnim (GUIComponentAnimation*) // GUIComponentButton::GetArea reads GUIComponentButton::mLabel location, not the button's. static constexpr std::size_t button_label_offset = 0x5'80; // GUIComponentButton::mLabel (GUIComponentTextBox*) @@ -167,18 +155,17 @@ namespace big::mod_settings static constexpr std::size_t component_def_scale_y_offset = 0x1'18; // mData.mDef.mScaleY (float) - // FreeFormSelectOffset is added to a component's location when the spatial keyboard/controller nav - // (SearchInDirection) evaluates it as a candidate. We use it to place the scroll arrows' eval point where the next - // or previous row would be, so the nav reaches an arrow at a page edge and its auto-activate fires the pager (see + // FreeFormSelectOffset is added to a component's location when the spatial keyboard/controller nav (SearchInDirection) + // evaluates it as a candidate. We use it to place the scroll arrows' eval point where the next or previous row would + // be, so the nav reaches an arrow at a page edge and its auto-activate fires the pager (see // enable_arrow_keyboard_paging). static constexpr std::size_t component_free_form_offset_x_offset = 0x1'54; // mFreeFormSelectOffsetX (float) static constexpr std::size_t component_free_form_offset_y_offset = 0x1'58; // mFreeFormSelectOffsetY (float) static constexpr std::size_t component_auto_activate_offset = 0x00'BC; // mAutoActivateWithGamepad (bool) // mData.mDef.mFreeFormSelectable: the spatial keyboard/controller nav (SearchInDirection) skips any candidate whose - // byte here is false, before it even calls IsSelectable. Mouse hover (MenuScreen::UpdateMouseOver) does not read - // it, so clearing it makes DOWN/UP nav jump over a row while the mouse can still hover it (to read its - // description). We clear it on disabled/greyed rows so the cursor only lands on interactable ones. + // byte here is false, before it even calls IsSelectable. Mouse hover (MenuScreen::UpdateMouseOver) does not read it, + // so clearing it makes DOWN/UP nav jump over a row while the mouse can still hover it (to read its description). static constexpr std::size_t component_free_form_selectable_offset = 0x00'B1; // mData.mDef.mFreeFormSelectable (bool) // The Button_Secondary sprite's native atlas width in px. The box draws at native * mScale * mScaleX. @@ -190,14 +177,9 @@ namespace big::mod_settings static constexpr float button_label_capacity = 15.0f; static constexpr float button_label_padding = 2.0f; - // sgg::GUIComponentSlider (the horizontal drag bar used by the audio-volume options). DIA-validated on the current - // Ship build, sizeof 0x5B0, derives directly from GUIComponent. It is a pure 0..1 fraction control (no min/max/step - // fields) - the value is mFraction and the fill graphic redraws from it. The game has no factory for it - // (DoShowCategory hand-rolls the allocation + the four sub-components), so make_slider_row replicates that - // construction. - // ??_7GUIComponentSlider@sgg@@6B@. Preferred by name at runtime (see set_up_hooks); this anchor-relative RVA is - // only a fallback if the vtable public symbol is absent from the map. Lives in .rdata, which shifts on updates, - // so keep it in sync when refreshing for a new build. + // sgg::GUIComponentSlider (the audio-volume drag bar). DIA-validated on the current Ship build, sizeof 0x5B0. + // DoShowCategory hand-builds it, so make_slider_row does too. ??_7GUIComponentSlider@sgg@@6B@ is preferred by + // name. This RVA is only a .rdata fallback and must be refreshed when the build changes. static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'68; static constexpr std::size_t slider_sizeof = 0x5'B0; static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking/mFill) @@ -213,10 +195,8 @@ namespace big::mod_settings static constexpr std::size_t slider_value_text_offset = 0x5'98; // mValueTextBox (GUIComponentTextBox*, right value) static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) - // GUIComponentSlider has no Draw-time highlight gate (unlike GUIComponentButton, whose Draw re-derives its - // highlight from mForceSelected/owner->mSelectedComponent). Its "moused-over" look (green label + fill) and - // "focused" look (green value) are child state set by OnMouseOver/OnFocusOn and reverted only by OnMouseOff/ - // OnFocusOff, so a stale flag survives across frames. mFocused is the slider's own bool the focus look tracks + // GUIComponentSlider has no Draw-time highlight gate (unlike GUIComponentButton, whose Draw re-derives its highlight + // from mForceSelected/owner->mSelectedComponent). mFocused is the slider's own bool the focus look tracks // mUseSelectedTextColor is the green-text flag on a child GUIComponentTextBox (the left label/right value). static constexpr std::size_t slider_focused_offset = 0x5'48; // GUIComponentSlider::mFocused (bool) static constexpr std::size_t textbox_use_selected_color_off = 0x5'52; // GUIComponentTextBox::mUseSelectedTextColor @@ -225,19 +205,16 @@ namespace big::mod_settings static constexpr std::size_t vtable_on_focus_off_offset = 0x1'18; // GUIComponent::OnFocusOff slot static constexpr std::size_t vtable_set_location_offset = 0x1'80; // GUIComponent::SetLocation slot (moves the component and its children) - // Disabled-greying of a slider/num-box, which are multi-sub-component widgets: the button-style def text greying - // does not reach their separate label/value text boxes or their bar/arrow graphics, so each is greyed directly. - // A GUIComponentTextBox renders its mDisabledText colour when mUseDisabledTextColor is set (Slider/NumBox Draw - // set it on the LABEL each frame from mIsUseable, but only if the box's def carries a non-negative disabled colour, - // so we write that colour explicitly and also flag the value box, which Draw never touches). A GUIComponentImage - // (slider bar) tints from mColor every frame, so writing mColor (and mColorTarget so a lerp does not undo it) dims - // it. Offsets on the text box/image component are absolute. + // Disabled-greying of a slider/num-box, which are multi-sub-component widgets: the button-style def text greying does + // not reach their separate label/value text boxes or their bar/arrow graphics, so each is greyed directly. A + // GUIComponentTextBox renders its mDisabledText colour when mUseDisabledTextColor is set (Slider/NumBox Draw set it on + // the LABEL each frame from mIsUseable, but only if the box's def carries a non-negative disabled colour, so we write + // that colour explicitly and also flag the value box, which Draw never touches). A GUIComponentImage (slider bar) + // tints from mColor every frame, so writing mColor (and mColorTarget so a lerp does not undo it) dims it. static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor - // Normal (0x1B4) and selected (0x1D0) text colours on the child text box, from the component def - // (component_def_offset 0xA8 + def_text_red 0x10C/def_sel_text_red 0x128). A Slider/NumBox Draw sets - // mUseDisabledTextColor from mIsUseable each frame, so a still-selectable (mIsUseable=1) greyed row would ignore - // the disabled colour and paint the bright normal (and, on hover, the selected) colour instead. Greying these two - // as well keeps the label grey in every state - matches how set_def_text_grey greys a button row's own def. + // Normal (0x1B4) and selected (0x1D0) text colours on the child text box, from the component def (component_def_offset + // 0xA8 + def_text_red 0x10C/def_sel_text_red 0x128). Greying these two as well keeps the label grey in every state - + // matches how set_def_text_grey greys a button row's own def. static constexpr std::size_t textbox_text_red = 0x1'B4; // mData.mDef.mTextRed (float) static constexpr std::size_t textbox_selected_text_red = 0x1'D0; // mData.mDef.mSelectedTextRed (float) static constexpr std::size_t textbox_disabled_text_red = 0x1'E8; // mData.mDef.mDisabledTextRed (float) @@ -248,21 +225,18 @@ namespace big::mod_settings static constexpr std::size_t image_color_target_offset = 0x00'78; // mColorTarget (packed RGBA) static constexpr std::size_t button_graphic_color_offset = 0x5'5C; // GUIComponentButton::mButtonColor - the colour Draw paints the toggle graphic with static constexpr std::size_t component_color_target_offset = 0x00'78; // GUIComponent::mColorTarget (Update eases mButtonColor toward this) - static constexpr std::size_t def_sel_red = 0xFC; // ComponentDataDef::mSelectedRed; set <0 to disable the selected-colour override in Draw/On(Un)Selected + static constexpr std::size_t def_sel_red = 0xFC; // ComponentDataDef::mSelectedRed - set <0 to disable the selected-colour override in Draw/On(Un)Selected static constexpr float disabled_text_grey = 0.22f; // matches set_def_text_grey (toggle/text rows) static constexpr std::uint32_t disabled_graphic_grey = 0xFF'66'66'66; // opaque 0.4 grey (packed A,B,G,R) - // GUIComponentTextBox::SetTextColor is vtable slot +0x160; it writes the cached runtime mTextColor (which the text - // box Draw renders when neither the disabled nor selected colour flag is set). The template caches a bright colour - // at build, and greying the def alone does not update it, so a still-selectable greyed widget label stays bright - - // we re-apply this grey through SetTextColor instead (the same call MiscSettingsScreen::UpdateButtonStates uses to - // grey a still-hoverable option). Packed the same A,B,G,R way as disabled_graphic_grey (opaque 0.22 grey). + // GUIComponentTextBox::SetTextColor is vtable slot +0x160. The template caches a bright colour at build, and greying + // the def alone does not update it, so a still-selectable greyed widget label stays bright - we re-apply this grey + // through SetTextColor instead (the same call MiscSettingsScreen::UpdateButtonStates uses to grey a still-hoverable + // option). static constexpr std::size_t vtable_set_text_color_offset = 0x1'60; static constexpr std::uint32_t disabled_label_grey_packed = 0xFF'38'38'38; // A GUIComponentAnimation (the num-box's box/frame graphic) tints from its own mColor. NumBox::OnSelected turns the - // box black by writing the selected colour here (opaque black for the OptionNumBox template); we write the same on - // a disabled num-box so its background matches the hovered look. NumBox Draw/Update never touch this field, so a - // one-time write on a non-selectable (disabled) box sticks. + // box black by writing the selected colour here (opaque black for the OptionNumBox template). static constexpr std::size_t animation_color_offset = 0x5'58; // GUIComponentAnimation::mColor (packed ARGB) static constexpr std::uint32_t numbox_hover_bg_black = 0xFF'00'00'00; // the num-box's hovered/selected box colour @@ -345,13 +319,15 @@ namespace big::mod_settings // hit rect (see row_bounded_area), replacing the native ones that union the slider's sub-components into a // screen-spanning rect. 128 slots comfortably covers the class's virtual table. static constexpr std::size_t slider_vtable_slot_count = 128; + // The highest slot we override or copy through is SetLocation at +0x180, so keep the buffer big enough for it. + static_assert(0x180 / sizeof(std::uintptr_t) < slider_vtable_slot_count, "vtable copy buffer too small for the highest patched slot"); static std::uintptr_t g_slider_vtable_copy[slider_vtable_slot_count] = {}; static std::uintptr_t g_slider_vtable_patched = 0; // runtime - // A patched copy of the GUIComponentButton vtable (built lazily in install_wide_button_nav_rect from the first - // action button's vtable) whose GetArea/GetScreenArea slots return the same wide one-row rect (row_bounded_area), - // so a centre-column action button is reachable by the vertical spatial nav. Installed only on enabled action - // buttons; every other button row keeps the native vtable. + // A patched copy of the GUIComponentButton vtable (built lazily in install_wide_button_nav_rect from the first action + // button's vtable) whose GetArea/GetScreenArea slots return the same wide one-row rect (row_bounded_area), so a + // centre-column action button is reachable by the vertical spatial nav. Every other button row keeps the native + // vtable. static std::uintptr_t g_button_vtable_copy[slider_vtable_slot_count] = {}; static std::uintptr_t g_button_vtable_patched = 0; // runtime static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) @@ -383,10 +359,8 @@ namespace big::mod_settings // as a plain text label. static std::uint32_t g_blank_graphic = 0; - // Panel layout, in native 1080p menu coordinates. The engine's UpdateScrollState pass positions each on-page row at - // Y = (index - pageStart) * row_pitch + row_base_y + ScreenCenterOffsetY, and X = the row's own location. Rows - // mirror the key-rebind ControlButton layout: the component is anchored to the right pane and its text is - // left-justified via a negative text offset, matching the native option-name column. + // Panel layout, in native 1080p menu coordinates. The engine's UpdateScrollState pass positions each on-page row at Y + // = (index - pageStart) * row_pitch + row_base_y + ScreenCenterOffsetY, and X = the row's own location. static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column static constexpr float value_text_offset_x = 15.0f; // right-justify the value, right edge aligns with the toggle's @@ -413,9 +387,9 @@ namespace big::mod_settings static constexpr const char* section_empty_key = "..."; // Approximate visual width budget for the right-column value (freetext + its edit caret), in "width units" where a - // typical medium glyph is 1.0 The menu font is variable-width, so a raw character count looks inconsistent (a run - // of 'W' is far wider than a run of 'i') budgeting by summed glyph weight keeps the shown value a consistent WIDTH - // so it does not run left into the key label. ~30 units is roughly 30 average glyphs wide. + // typical medium glyph is 1.0 The menu font is variable-width, so a raw character count looks inconsistent (a run of + // 'W' is far wider than a run of 'i') budgeting by summed glyph weight keeps the shown value a consistent WIDTH so it + // does not run left into the key label. static constexpr float value_display_max_width = 30.0f; // Edit-cursor blink half-period (ms): the "|" shows for this long, then hides. @@ -461,9 +435,7 @@ namespace big::mod_settings GUIComponent* value_component = nullptr; // Bounded number setting (metadata has both min and max). Rendered as a native slider (drag bar) spanning - // [stepper_min, stepper_max] and snapped to stepper_step. is_slider marks that. If the slider cannot be built - // it falls back to a number-box stepper (is_stepper) that steps by stepper_step. A number without bounds uses - // the freetext editor instead. + // [stepper_min, stepper_max] and snapped to stepper_step. bool is_slider = false; bool is_stepper = false; double stepper_min = 0.0; @@ -485,10 +457,8 @@ namespace big::mod_settings // Group rows (RowKind::group) only: the child config section this row drills into. std::string target_section; - // The entry's REAL config section (for virtual-row Lua I/O: get/set/text). A `group` override can place a row - // on a menu page whose path differs from the entry's config section, so runtime commits must use this, not the - // view path. Config settings carry their section on `entry`; actions on `target_section`; only virtual rows - // need this stored. Empty -> falls back to the current view path. + // The entry's REAL config section (for virtual-row Lua I/O: get/set/text). A `group` override can place a row on a + // menu page whose path differs from the entry's config section, so runtime commits must use this, not the view path. std::string config_section; }; @@ -511,10 +481,8 @@ namespace big::mod_settings // The native restart message box's (only) button clicking it closes the game (restart). static GUIComponent* g_restart_confirm_button = nullptr; - // The restart message box itself (owner of g_restart_confirm_button). Used only to re-validate that a clicked - // button really is the live restart dialog's button before terminating: matching the button pointer alone would be - // fooled if that dialog were freed and another button reused its address. A genuine restart button's owner is this - // dialog any rebuilt row's owner is the options screen, so it will not match. + // The restart message box itself (owner of g_restart_confirm_button). A genuine restart button's owner is this dialog + // any rebuilt row's owner is the options screen, so it will not match. static void* g_restart_dialog = nullptr; // True once the restart prompt has been shown this menu session (so closing again proceeds). @@ -546,6 +514,7 @@ namespace big::mod_settings std::string stem; std::string section; std::string key; + std::string config_section; // real config section, to distinguish same-named keys grouped onto one page }; // The clicked row to hold as hovered/selected across a click-triggered instant rebuild, captured in the OnClicked @@ -570,8 +539,8 @@ namespace big::mod_settings // Navigation restore stack: one entry per drill-in level (the mod list into a mod, or a section into a child group) // Each records the parent view's scroll offset and the identity of the row drilled through, so backing out restores - // that scroll and re-selects that row instead of snapping to the top focus_stem identifies a mod row (returning to - // the mod list) focus_section identifies a group row by its target section (returning to a parent section) + // that scroll and re-selects that row instead of snapping to the top focus_stem identifies a mod row (returning to the + // mod list) focus_section identifies a group row by its target section (returning to a parent section) // g_pending_restore holds the entry popped by the current back-navigation for build_panel to consume. struct NavRestore { @@ -596,10 +565,10 @@ namespace big::mod_settings static bool g_edit_confirm = false; static bool g_edit_cancel = false; - // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the author (up to the first '-') and - // runs the mod name through key_to_display, so '_' becomes a space and camelCase/PascalCase word boundaries are - // split - the same friendly-name logic used for setting keys "SGG_Modding-Chalk" -> "Chalk". - // "NikkelM-Zagreus_Journey" -> "Zagreus Journey" "zerp-DreamDiveTweaks" -> "Dream Dive Tweaks". + // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the author (up to the first '-') and runs + // the mod name through key_to_display, so '_' becomes a space and camelCase/PascalCase word boundaries are split - the + // same friendly-name logic used for setting keys "SGG_Modding-Chalk" -> "Chalk". "NikkelM-Zagreus_Journey" -> "Zagreus + // Journey" "zerp-DreamDiveTweaks" -> "Dream Dive Tweaks". static std::string key_to_display(const std::string& key); // shared friendly-name logic, defined below static std::string display_name_from_stem(const std::string& stem) @@ -646,13 +615,13 @@ namespace big::mod_settings return !custom.empty() ? custom : opt_out_note(); } - // Escapes the characters the game's text parser (GUIComponentTextBox::Parse) treats as markup, so arbitrary user - // text - config values (e.g. Windows paths with '\'), display names and descriptions - renders verbatim instead of - // being mangled. The parser reads '\' as an escape lead that consumes the following word ("D:\Program..." -> "D: - // ...") and '[' ']' as inline-tag delimiters whose contents are dropped ("[deprecated] x" -> " x"). A leading - // backslash makes each literal (\\ -> \, \[ -> [, \] -> ]) backslash MUST be escaped first ('{' and '@' are also - // markup leads but have no literal escape in the parser, so are left as-is - they are rare in config text and, - // unlike '\'/'[', do not silently eat surrounding characters.). + // Escapes the characters the game's text parser (GUIComponentTextBox::Parse) treats as markup, so arbitrary user text + // - config values (e.g. Windows paths with '\'), display names and descriptions - renders verbatim instead of being + // mangled. The parser reads '\' as an escape lead that consumes the following word ("D:\Program..." -> "D: ...") and + // '[' ']' as inline-tag delimiters whose contents are dropped ("[deprecated] x" -> " x"). A leading backslash makes + // each literal (\\ -> \, \[ -> [, \] -> ]) backslash MUST be escaped first ('{' and '@' are also markup leads but have + // no literal escape in the parser, so are left as-is - they are rare in config text and, unlike '\'/'[', do not + // silently eat surrounding characters.). static std::string escape_markup(const std::string& text) { std::string out; @@ -668,11 +637,8 @@ namespace big::mod_settings return out; } - // --- Text metrics + caret helpers (byte indices into a string UTF-8 aware) --- Approximate width of a single byte - // in the value font, in the same units as value_display_max_width (medium glyph. = 1.0). The menu font - // (P22UndergroundSCMedium) is variable-width these rough classes are enough to fit values by visual width instead - // of raw character count (exact pixel measurement is intentionally avoided - it would need the engine's SpriteFont - // globals). A UTF-8 lead byte counts once as a medium glyph continuation bytes add 0. + // --- Text metrics + caret helpers (byte indices into a string UTF-8 aware) --- Approximate width of a single byte in + // the value font, in the same units as value_display_max_width (medium glyph. static float glyph_weight(unsigned char c) { if (c >= 0xC0) @@ -798,9 +764,7 @@ namespace big::mod_settings } // Caps an over-wide value string for the right-aligned value column so it does not run left into the option's key - // label. Keeps the TAIL with a leading ellipsis (most informative for a path, and where the append/backspace edit - // caret sits). Fits by summed glyph WIDTH, not character count, so wide/narrow text shows a consistent visual - // width. Operates on the logical (pre-escape) string escape the result afterwards. + // label. Fits by summed glyph WIDTH, not character count, so wide/narrow text shows a consistent visual width. static std::string truncate_value(const std::string& text) { if (measure_width(text) <= value_display_max_width) @@ -840,12 +804,10 @@ namespace big::mod_settings button->m_hidden = false; button->m_is_useable = true; - // Point the button's localization id at "Mods" so the engine's own label pipeline resolves it. The reused - // button ships with DisplayNameId "MiscSettingsScreen_EditorOptions" (-> "Editor") interning "Mods" and writing - // its id into mDisplayNameId makes. GUIComponentButton::UseDefaultText re-derive "Mods" natively - including - // after a language change, which re-runs that derivation and would otherwise revert the tab to "Editor" "Mods" - // has no text-data entry, so the lookup misses and the engine renders the raw key ("Mods") verbatim in every - // language. + // Point the button's localization id at "Mods" so the engine's own label pipeline resolves it. + // GUIComponentButton::UseDefaultText re-derive "Mods" natively - including after a language change, which re-runs + // that derivation and would otherwise revert the tab to "Editor" "Mods" has no text-data entry, so the lookup misses + // and the engine renders the raw key ("Mods") verbatim in every language. if (g_hash_lookup) { HashGuid id{}; @@ -928,12 +890,11 @@ namespace big::mod_settings g_set_normal_texture(row, is_on ? on_hash : off_hash, false); } - // Reproduces the vanilla toggle click sound. A native ConfigOptions toggle plays mToggleOnSound/mToggleOffSound - // from its ValueChanged handler (MiscSettingsScreen::ToggleOptionValueChanged), which our C++ toggle path replaces, - // so a toggle would otherwise be silent (the base GUIComponent::OnClicked only plays mPressSound, which the + // Reproduces the vanilla toggle click sound. A native ConfigOptions toggle plays mToggleOnSound/mToggleOffSound from + // its ValueChanged handler (MiscSettingsScreen::ToggleOptionValueChanged), which our C++ toggle path replaces, so a + // toggle would otherwise be silent (the base GUIComponent::OnClicked only plays mPressSound, which the // OptionToggleButton template leaves unset). We copy the cue for the value the click will produce into mPressSound - // just before the base OnClicked runs, so its own audio path plays it with the correct swap handling. The rows are - // built on the OptionToggleButton template, so they already carry both toggle cues in their def. + // just before the base OnClicked runs, so its own audio path plays it with the correct swap handling. static void stage_toggle_press_sound(GUIComponent* row, bool new_value) { char* def = reinterpret_cast(row) + component_def_offset; @@ -946,7 +907,7 @@ namespace big::mod_settings static void set_def_text_grey(GUIComponent* row) { char* def = reinterpret_cast(row) + component_def_offset; - constexpr float grey = 0.22f; + constexpr float grey = disabled_text_grey; *reinterpret_cast(def + def_text_red) = grey; *reinterpret_cast(def + def_text_green) = grey; *reinterpret_cast(def + def_text_blue) = grey; @@ -956,9 +917,7 @@ namespace big::mod_settings } // Greys a child GUIComponentTextBox (a slider/num-box label or value box) by giving it a disabled text colour and - // flagging it to use that colour. Draw greys only the LABEL (from the parent's mIsUseable) and only when the box's - // def already carries a disabled colour, so we set the colour here; for the value box, which Draw never touches, - // the flag persists too. Grey text colour matches set_def_text_grey so every disabled row reads the same. + // flagging it to use that colour. Grey text colour matches set_def_text_grey so every disabled row reads the same. static void grey_text_box(void* text_box) { if (!text_box) @@ -973,9 +932,8 @@ namespace big::mod_settings *reinterpret_cast(b + textbox_use_disabled_color_off) = true; // Also grey the normal and selected text colours (red/green/blue triples). A still-selectable greyed row keeps - // mIsUseable=1, so Slider/NumBox Draw clears mUseDisabledTextColor and the label falls back to the normal - // colour (and the selected colour on hover) - greying both keeps it greyed in every state, with no hover - // highlight. The disabled-only path (mIsUseable=0) is unaffected since these just match the disabled grey. + // mIsUseable=1, so Slider/NumBox Draw clears mUseDisabledTextColor and the label falls back to the normal colour (and + // the selected colour on hover) - greying both keeps it greyed in every state, with no hover highlight. for (const std::size_t base : {textbox_text_red, textbox_selected_text_red}) { *reinterpret_cast(b + base + 0x0) = disabled_text_grey; @@ -998,13 +956,12 @@ namespace big::mod_settings *reinterpret_cast(b + image_color_target_offset) = disabled_graphic_grey; } - // Greys a disabled toggle's on/off ring so it reads greyed from frame one. The ring (mNormalTexture) is a bare - // texture id with no colour of its own - GUIComponentButton::Draw paints it with mButtonColor@0x55C, which starts - // black and the engine only eases to the greyed mColorTarget@0x78 in Update/on selection, so an untouched disabled - // toggle shows black until a hover eases it grey. We set the live paint colour AND the ease target to the disabled - // grey (so Update sees them equal and never eases away), and set the def's mSelectedRed < 0 so Draw/On(Un)Selected - // skip the selected-colour override - the ring then reads greyed at rest and stays greyed through hover/selection. - // All are fixed-offset writes on the button itself (no child-pointer dereference), unlike the slider's owned images. + // Greys a disabled toggle's on/off ring so it reads greyed from frame one. The ring (mNormalTexture) is a bare texture + // id with no colour of its own - GUIComponentButton::Draw paints it with mButtonColor@0x55C, which starts black and + // the engine only eases to the greyed mColorTarget@0x78 in Update/on selection, so an untouched disabled toggle shows + // black until a hover eases it grey. We set the live paint colour AND the ease target to the disabled grey (so Update + // sees them equal and never eases away), and set the def's mSelectedRed < 0 so Draw/On(Un)Selected skip the + // selected-colour override - the ring then reads greyed at rest and stays greyed through hover/selection. static void grey_toggle_graphic(GUIComponent* row) { char* b = reinterpret_cast(row); @@ -1014,12 +971,9 @@ namespace big::mod_settings } // Sets a row's normal text colour to the native settings-option grey (0.55) used by the game's own. - // OptionToggleButton/OptionNumBox rows, so plain-text (key/value) rows built on the CategoryOptionsButton - // template (whose own text is a darker 0.35) match the toggle rows instead of reading as brighter full white. The - // selected colour is left as the template's (the same green highlight both templates use) so hover still - // highlights - unless also_selected is set, which pins the selected colour to the same grey so a hovered row shows - // no highlight change (used for non-interactive info rows: normal-looking text, hoverable for its description, but - // no hover glow). Must run before SetupComponent to reach the text box. + // OptionToggleButton/OptionNumBox rows, so plain-text (key/value) rows built on the CategoryOptionsButton template + // (whose own text is a darker 0.35) match the toggle rows instead of reading as brighter full white. Must run before + // SetupComponent to reach the text box. static void set_def_text_normal(GUIComponent* row, bool also_selected = false) { char* def = reinterpret_cast(row) + component_def_offset; @@ -1035,16 +989,8 @@ namespace big::mod_settings } } - // A plain left-justified text row (mod names, Back, and non-toggle settings). Applies a template for valid - // font/colours, then retunes the row's own def into the key-rebind "ControlButton" style - no background graphic, - // left text, and a text-area hit region that hugs the label - and clears any leftover textures. Disabled rows are - // greyed. By default they are also hard-disabled (non-selectable). Pass block_input=false to grey a row while - // keeping it selectable, so it can still be highlighted to show its description (used for opted-out mods, whose row - // is greyed and shows a note but must not be drilled into). Pass no_hover_highlight=true for a non-interactive info - // row: it keeps normal (non-grey) text and stays mouse-hoverable (so its description shows), but the hover shows no - // green highlight (the selected text colour is pinned to the normal colour). Pair with pr.disabled to also skip - // keyboard/controller nav, giving a row the mouse can rest on to read its description but that neither cursor - // selects. + // A plain left-justified text row (mod names, Back, and non-toggle settings). Disabled rows are greyed. By default + // they are also hard-disabled (non-selectable). static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true, bool no_hover_highlight = false) { auto* row = create_button(screen); @@ -1115,10 +1061,8 @@ namespace big::mod_settings // A toggle row (boolean setting): a left-justified label plus the native on/off toggle switch graphic on the right. // The OptionToggleButton template already supplies the toggle graphic, left-justified text and text area we only - // realign it to our row grid (mY/mSpacing, read directly by UpdateScrollState) and choose the on/off graphic. - // Disabled rows grey their ring (grey_toggle_graphic) and label from frame one. By default (block_input=true, the - // whole-mod-off case) they also drop mIsUseable via Disable so keyboard nav and mouse hover skip the row. Pass - // block_input=false to keep the row mouse-hoverable for its note (context/author-disabled), still greyed. + // realign it to our row grid (mY/mSpacing, read directly by UpdateScrollState) and choose the on/off graphic. Disabled + // rows grey their ring (grey_toggle_graphic) and label from frame one. static GUIComponent* make_toggle_row(MiscSettingsScreen* screen, const char* label, bool is_on, bool disabled = false, bool block_input = true) { auto* row = create_button(screen); @@ -1166,10 +1110,8 @@ namespace big::mod_settings if (block_input && g_disable) { - // Whole-mod-off toggle: also drop mIsUseable (via Disable) so keyboard nav and mouse hover skip the row - // - the same path the menu has always used for a disabled option (edits are already blocked by - // pr.disabled). A context/author-disabled toggle (block_input=false) keeps mIsUseable so it stays - // mouse-hoverable for its note. + // Whole-mod-off toggle: also drop mIsUseable via Disable so nav and hover skip the row. A + // context/author-disabled toggle keeps mIsUseable so it stays mouse-hoverable for its note. g_disable(row); } } @@ -1178,12 +1120,9 @@ namespace big::mod_settings return row; } - // A centered native button row (for actions like Apply/Reset), using the CategoryOptionsButton template unchanged - // so it keeps its Button_Secondary box graphic and centered label - visually distinct from the plain-text setting - // rows. Only the row grid position (mY/mSpacing) is overridden. Disabled rows are greyed by default they are also - // hard-disabled (non-selectable). Pass block_input=false to grey a row while keeping it selectable, so it can still - // be highlighted to show its description note (used for a context-restricted action, which is greyed but must still - // explain why. It is unavailable). + // A centered native button row (for actions like Apply/Reset), using the CategoryOptionsButton template unchanged so + // it keeps its Button_Secondary box graphic and centered label - visually distinct from the plain-text setting rows. + // Disabled rows are greyed by default they are also hard-disabled (non-selectable). static void install_wide_button_nav_rect(GUIComponent* row); // defined below (near row_bounded_area) static GUIComponent* make_button_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) { @@ -1210,16 +1149,14 @@ namespace big::mod_settings *reinterpret_cast(def + def_scale) = button_scale; // shrink slightly for top/bottom breathing room // The hover/click rect is GetArea = mCustomWidth * mScale@0x38 * mScaleX@0x114. The drawn box already reflects - // button_scale and mScaleX@0x114 carries box_scale_x below, so mCustomWidth is the plain native width. Baking - // button_scale in here as well applies it twice and pulls the hit rect inside the drawn box. This native width - // also seeds the label's copied def width, which is widened back below so the label does not wrap. + // button_scale and mScaleX@0x114 carries box_scale_x below, so mCustomWidth is the plain native width. *reinterpret_cast(def + def_width) = button_graphic_native_width; *reinterpret_cast(def + def_height) = 58.0f; // Momentary selection: the CategoryOptionsButton template keeps a button selected (its highlight lit) after a // mouse-off - correct for the category tabs, but an action button should not stay lit like a selected tab once - // clicked. mDeselectOnMouseOff makes the highlight clear when the cursor leaves (the highlight still shows - // while hovered), so the action button reads as momentary. + // clicked. mDeselectOnMouseOff makes the highlight clear when the cursor leaves (the highlight still shows while + // hovered), so the action button reads as momentary. *reinterpret_cast(def + def_deselect_on_mouse_off) = true; if (disabled) @@ -1246,11 +1183,8 @@ namespace big::mod_settings } // Widen the box graphic to box_scale_x. The box is a single-frame animation reached via mAnim enabling - // mScaleModifierOnlyX makes GUIComponentAnimation::Draw honour the anim's own def mScaleX (horizontal-only), - // which the button otherwise leaves at a uniform scale. The selection highlight (mSelectedTexture, drawn as an - // overlay) is instead scaled by the BUTTON's own def mScaleX/mScaleY (the button's Drawable), independent of - // the box's mAnim - so set those too, by the same factor, to keep the highlight's designed glow margin around - // the widened box. + // mScaleModifierOnlyX makes GUIComponentAnimation::Draw honour the anim's own def mScaleX (horizontal-only), which + // the button otherwise leaves at a uniform scale. if (box_scale_x > 1.0f) { // component_def_scale_* offsets are from the component base @@ -1272,12 +1206,11 @@ namespace big::mod_settings g_disable(row); } - // The CategoryOptionsButton template is shared with the top category tabs (paged by bumpers, not the vertical - // option nav), so it leaves mData.mDef.mFreeFormSelectable unset - meaning the up/down spatial nav - // (SearchInDirection) skips it. Opt an enabled action button in, and give it a wide option-column nav rect - // (install_wide_button_nav_rect): its native GetArea is a narrow rect at the centered label, which a vertical - // nav ray down the option column never crosses, so nav would still skip it. A disabled action stays skipped - // (apply_row_freeform_selectability clears the flag for every disabled row after the build). + // The CategoryOptionsButton template is shared with the top category tabs (paged by bumpers, not the vertical option + // nav), so it leaves mData.mDef.mFreeFormSelectable unset - meaning the up/down spatial nav (SearchInDirection) skips + // it. Opt an enabled action button in, and give it a wide option-column nav rect (install_wide_button_nav_rect): its + // native GetArea is a narrow rect at the centered label, which a vertical nav ray down the option column never + // crosses, so nav would still skip it. if (!disabled) { *reinterpret_cast(row_bytes + component_free_form_selectable_offset) = true; @@ -1293,10 +1226,8 @@ namespace big::mod_settings } // A right-justified, non-interactive value label for the right column of a key/value setting row (paired with a - // left-column key row). It is NOT added to mOptions: the engine's scroll pass lays out only mOptions rows by index - // and would stack a second per-row entry, so instead the value follows its key row each frame (sync_value_columns). - // It shares the key's component X anchor but uses RIGHT justification, so the value sits in the right column while - // the key stays left. + // left-column key row). It shares the key's component X anchor but uses RIGHT justification, so the value sits in the + // right column while the key stays left. static GUIComponent* make_value_display(MiscSettingsScreen* screen, const char* text, bool disabled) { auto* row = create_button(screen); @@ -1378,14 +1309,12 @@ namespace big::mod_settings } } - // Builds a native sgg::GUIComponentNumBox stepper row - identical to the game's own FPS-limit/graphics-quality - // options (boxed value flanked by Arrow_Left/Arrow_Right, left/right + arrow-click stepping, keyboard + - // controller). The game's factory allocates it, sets the correct vtable and builds all five sub-components (box - // graphic, label, value text, both arrows), which are also freed automatically when the row vectors are torn down - - // so no manual cleanup is needed. Value edits are persisted by the SetNumberValue hook (filtered to our rows). - // Returns the num-box component (not a GUIComponentButton, so it never routes through the OnClicked hook). When - // `value_labels` is non-null. The box is an enum cycler: it steps the integer index and its value text is - // overridden to the matching label instead of the raw number. + // Builds a native sgg::GUIComponentNumBox stepper row - identical to the game's own FPS-limit/graphics-quality options + // (boxed value flanked by Arrow_Left/Arrow_Right, left/right + arrow-click stepping, keyboard + controller). The + // game's factory allocates it, sets the correct vtable and builds all five sub-components (box graphic, label, value + // text, both arrows), which are also freed automatically when the row vectors are torn down - so no manual cleanup is + // needed. Value edits are persisted by the SetNumberValue hook (filtered to our rows). Returns the num-box component + // (not a GUIComponentButton, so it never routes through the OnClicked hook). static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled, const std::vector* value_labels = nullptr, bool block_input = true) { if (!g_numbox_factory || !g_numbox_set_range || !g_numbox_set_value || !g_apply_data || !g_show_text) @@ -1430,8 +1359,8 @@ namespace big::mod_settings // ApplyDataToComponent copies the OptionNumBox template's own row grid (Y=300, Spacing=45) into the component // override it to our grid so the box lines up with the other rows instead of drawing on the previous one - // def_y/def_spacing alias the component's baseY(+0xC8) and pitch(+0x204) that UpdateScrollState reads (def sits - // at component+0xA8). + // def_y/def_spacing alias the component's baseY(+0xC8) and pitch(+0x204) that UpdateScrollState reads (def sits at + // component+0xA8). { char* def = nb_bytes + component_def_offset; *reinterpret_cast(def + def_y) = row_base_y; @@ -1464,13 +1393,11 @@ namespace big::mod_settings if (disabled) { - // mDisableInput is the num-box's own input gate (its HandleInput early-outs on it), blocking both the - // arrow-clicks and keyboard/controller stepping - mIsUseable does NOT gate num-box input, so it is always - // set on a disabled box. Grey the label and value boxes (grey_text_box also greys their normal/selected - // colours so a still-selectable box stays greyed and does not highlight on hover). When block_input is set - // (the whole-mod-off case) also clear mIsUseable so nav/hover skip it and force the box to the hovered - // black so it reads consistently; a still-selectable (block_input=false) context/author-disabled box keeps - // mIsUseable so it stays hoverable for its note and leaves the box graphic at its greyed default. + // mDisableInput is the num-box's own input gate (its HandleInput early-outs on it), blocking both the arrow-clicks + // and keyboard/controller stepping - mIsUseable does NOT gate num-box input, so it is always set on a disabled box. + // Grey the label and value boxes (grey_text_box also greys their normal/selected colours so a still-selectable box + // stays greyed and does not highlight on hover). When block_input is set (the whole-mod-off case) also clear + // mIsUseable so nav/hover skip it and force the box to the hovered black so it reads consistently. *reinterpret_cast(nb_bytes + numbox_disable_input_offset) = true; grey_text_box(*reinterpret_cast(nb_bytes + numbox_label_text_offset)); grey_text_box(*reinterpret_cast(nb_bytes + numbox_value_text_offset)); @@ -1489,10 +1416,8 @@ namespace big::mod_settings return nb; } - // Formats a numeric setting value for display. is_pct shows a 0..1 value as 0..100 and appends "%". show_as_pct - // only appends "%" (no scaling). Setting both is the same as is_pct alone. The value is rounded to the display - // step's precision so scaling by 100 does not surface floating-point noise, then trailing zeros are trimmed ("53", - // "0.5", "50%"). + // Formats a numeric setting value for display. The value is rounded to the display step's precision so scaling by 100 + // does not surface floating-point noise, then trailing zeros are trimmed ("53", "0.5", "50%"). static std::string format_setting_display(double value, bool show_as_pct, bool is_pct, double step) { double shown = is_pct ? value * 100.0 : value; @@ -1539,19 +1464,13 @@ namespace big::mod_settings } } - // Builds a native sgg::GUIComponentSlider row - the horizontal drag bar the audio-volume options use - for a - // bounded numeric setting. The slider stores a normalized 0..1 fraction. We map the setting's [min,max] onto it and - // snap drags to `step` in the SetFraction hook. The engine has no factory for this type, so this replicates the - // construction DoShowCategory performs for the volume rows: allocate the block, run the base GUIComponent - // Row-sized hover/nav hit rect for our custom rows whose native GetArea is unsuitable, installed via a patched - // vtable on the GetArea (+0x98) and GetScreenArea (+0xA0) slots. Two rows need it: interactive sliders (whose - // native GUIComponentSlider::GetArea unions the bar/fill/label/value sub-components into a near screen-spanning - // rectangle that steals mouse hover from every other row via the nearest-anchor tiebreak in - // MenuScreen::UpdateMouseOver), and centered action buttons (whose GUIComponentButton::GetArea is derived from the - // CENTERED label, a narrow rect at the button centre that a vertical nav ray down the option column never crosses, - // so the up/down nav skips them). Returning a one-row rect spanning the option column makes both hit-test and - // nav-test like any other row (the toggle/text rows' footprint). Slider dragging is unaffected: it runs through - // GUIComponentSlider::HandleInput (hooked separately), not GetArea. IRectangle is {x,y,w,h} int32. + // Row-sized hover/nav hit rect for our custom rows whose native GetArea is unsuitable, installed via a patched vtable + // on the GetArea (+0x98) and GetScreenArea (+0xA0) slots. Two rows need it: interactive sliders (whose native + // GUIComponentSlider::GetArea unions the bar/fill/label/value sub-components into a near screen-spanning rectangle + // that steals mouse hover from every other row via the nearest-anchor tiebreak in MenuScreen::UpdateMouseOver), and + // centered action buttons (whose GUIComponentButton::GetArea is derived from the CENTERED label, a narrow rect at the + // button centre that a vertical nav ray down the option column never crosses, so the up/down nav skips them). Slider + // dragging is unaffected: it runs through GUIComponentSlider::HandleInput (hooked separately), not GetArea. static void* row_bounded_area(GUIComponent* self, std::int32_t* out) { const int left = static_cast(row_location_x + row_text_offset_x); // option-name column start (~660) @@ -1562,30 +1481,35 @@ namespace big::mod_settings return out; } - // Installs the patched button vtable (GetArea/GetScreenArea -> row_bounded_area, a wide one-row option-column rect) - // on `row`, building the copy lazily from the row's current (native) vtable on first use. A centre-column action - // button's native GetArea is a narrow rect at the button centre that the vertical nav ray never crosses; the wide - // rect makes it reachable like any setting row. The copy is byte-identical apart from the two area getters, so the - // destructor destroy_rows invokes and every other virtual behave exactly as the native button. + // Copies vtable `src` into `dst` and redirects the GetArea (+0x98) and GetScreenArea (+0xA0) slots to + // row_bounded_area, so the row hit- and nav-tests as one option-column row. The copy is byte-identical otherwise, + // so every other virtual (ctor/dtor/Draw/HandleInput/...) behaves as native. Returns dst as a vtable pointer. + static std::uintptr_t build_row_area_vtable(std::uintptr_t* dst, std::size_t dst_bytes, std::uintptr_t src) + { + std::memcpy(dst, reinterpret_cast(src), dst_bytes); + dst[0x98 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); + dst[0xA0 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); + return reinterpret_cast(dst); + } + + // Installs the patched button vtable (see build_row_area_vtable) on `row`, building the copy lazily from the row's + // current native vtable on first use. A centre-column action button's native GetArea is a narrow rect at the button + // centre that the vertical nav ray never crosses. The wide rect makes it reachable like any setting row. static void install_wide_button_nav_rect(GUIComponent* row) { if (!g_button_vtable_patched) { const std::uintptr_t native_vtable = *reinterpret_cast(row); - std::memcpy(g_button_vtable_copy, reinterpret_cast(native_vtable), sizeof(g_button_vtable_copy)); - g_button_vtable_copy[0x98 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); - g_button_vtable_copy[0xA0 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); - g_button_vtable_patched = reinterpret_cast(g_button_vtable_copy); + g_button_vtable_patched = build_row_area_vtable(g_button_vtable_copy, sizeof(g_button_vtable_copy), native_vtable); } *reinterpret_cast(row) = g_button_vtable_patched; } - // constructor, install the slider vtable, zero the fields Defaults leaves untouched, then run Defaults and allocate - // the four owned sub-components (bar background, fill, label, value text). Named "OptionSlider" so - // ApplyDataToComponent applies the matching sjson template (bar graphics, colours, FadeSpeed, label styling). - // Teardown mirrors the num-box: destroy_rows routes it through the vtable deleting destructor (which frees the - // sub-components) then _aligned_free. Returns null if any required engine helper is missing, in which case the - // caller falls back to a number-box stepper. + // Builds a native sgg::GUIComponentSlider row (the volume-style horizontal drag bar) for a bounded numeric setting. + // The slider stores a normalized 0..1 fraction: we map the setting's [min,max] onto it and snap drags to `step` in the + // SetFraction hook. The engine exposes no factory for this type, so this replicates the construction DoShowCategory + // performs for the volume rows and names the row "OptionSlider" so ApplyDataToComponent applies the matching sjson + // template. static GUIComponent* make_slider_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool show_as_pct, bool is_pct, bool disabled, bool block_input = true) { if (!g_gui_component_ctor || !g_image_ctor || !g_textbox_ctor || !g_slider_defaults || !g_slider_set_fraction || !g_slider_vtable || !g_apply_data || !g_show_text) @@ -1672,12 +1596,9 @@ namespace big::mod_settings if (disabled) { - // Grey every visible part explicitly: the value box and both bar images, plus the label (which Slider::Draw - // would otherwise only grey off mIsUseable). block_input=true also clears mIsUseable so nav/hover skip the - // row (the whole-mod-off case). block_input=false keeps it selectable so a context-restricted or - // author-disabled slider stays greyed-but-visible and hoverable to show its note, with edits blocked by - // pr.disabled. Mouse-drag is separately blocked in the HandleInput hook (the native drag path ignores - // mIsUseable). + // Grey every visible part explicitly: the value box and both bar images, plus the label (which Slider::Draw would + // otherwise only grey off mIsUseable). Mouse-drag is separately blocked in the HandleInput hook (the native drag + // path ignores mIsUseable). auto* sc = reinterpret_cast(s); if (block_input) { @@ -1710,10 +1631,10 @@ namespace big::mod_settings } } - // Tears down every custom row we currently own: clears any screen pointer that still references a row (so the - // engine cannot dereference it after free), unlinks it from the drawn/hit-tested mComponents and the paged - // mOptions, then destroys and frees it. Our rows are not registered in the reflection helper, so the engine never - // frees them and never double-frees here. Safe to call when g_rows is empty or already unlinked. + // Tears down every custom row we currently own: clears any screen pointer that still references a row (so the engine + // cannot dereference it after free), unlinks it from the drawn/hit-tested mComponents and the paged mOptions, then + // destroys and frees it. Our rows are not registered in the reflection helper, so the engine never frees them and + // never double-frees here. static void destroy_rows(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); @@ -1753,10 +1674,9 @@ namespace big::mod_settings if (owns_subcomponents) { - // The num-box and slider are not GUIComponentButtons destruct through the component's own vtable so its - // owned sub-components (num-box: box/label/value/arrows slider: background/fill/label/value) are freed - // too flags=0 destructs without the final operator delete, so we still _aligned_free the block - // ourselves. + // The num-box and slider are not GUIComponentButtons destruct through the component's own vtable so its owned + // sub-components (num-box: box/label/value/arrows slider: background/fill/label/value) are freed too flags=0 + // destructs without the final operator delete, so we still _aligned_free the block ourselves. void** vtbl = *reinterpret_cast(comp); auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset / sizeof(void*)]); dtor(comp, 0); @@ -1814,10 +1734,9 @@ namespace big::mod_settings for (const auto& [display, stem] : mods) { - // A mod that called rom.mod_settings.opt_out() is still listed (dropping it would look like a missing mod), - // but its row is greyed and cannot be opened, and its description is a note pointing back to the mod's own - // description. The row is greyed without hard-disabling it so it stays selectable and the note still shows - // on hover/focus. The drilldown is blocked by the disabled flag in the click handler. + // A mod that called rom.mod_settings.opt_out() is still listed (dropping it would look like a missing mod), but its + // row is greyed and cannot be opened, and its description is a note pointing back to the mod's own description. The + // drilldown is blocked by the disabled flag in the click handler. const bool opted_out = mod_opted_out(stem); if (auto* row = make_text_row(screen, escape_markup(display).c_str(), opted_out, /*block_input*/ false)) { @@ -1830,10 +1749,7 @@ namespace big::mod_settings } // Turns an identifier into a friendly display string: underscores become spaces, and camelCase/PascalCase word - // boundaries are split ("z_ThisConfigKey" -> "z. This Config Key"). An acronym run splits before its final capital - // when that capital starts a lowercase word ("HTTPServer" -> "HTTP. Server"). The first letter is capitalized - // ("enabled" -> "Enabled"). Used for both setting keys and mod names (via display_name_from_stem). Authors can - // override this entirely with `display_name`. + // boundaries are split ("z_ThisConfigKey" -> "z. The first letter is capitalized ("enabled" -> "Enabled"). static std::string key_to_display(const std::string& key) { const auto is_upper = [](char c) @@ -1884,12 +1800,8 @@ namespace big::mod_settings return out; } - // Renders the edit buffer with a caret marker at `cursor`, windowed by visual WIDTH so the caret stays visible and - // the whole string fits the value column (value_display_max_width) without running into the key label. The window - // grows outward from the caret (both sides) filling the budget by summed glyph width, reserving space for the caret - // and for whichever ellipses are actually shown. The caret is a blinking ". "/" " hidden text is marked with a - // leading/trailing ellipsis. Each shown buffer segment is markup-escaped (a path may contain '\'). The caret and - // ellipses are literal. + // Renders the edit buffer with a caret marker at `cursor`, windowed by visual WIDTH so the caret stays visible and the + // whole string fits the value column (value_display_max_width) without running into the key label. static std::string render_edit_display(const std::string& buf, std::size_t cursor, bool blink_on) { const char* caret = blink_on ? "|" : " "; @@ -1982,10 +1894,8 @@ namespace big::mod_settings } // Window-procedure callback: while a freetext setting is being edited, capture typed characters and caret movement - // into the edit buffer. Runs on the game's message-pump thread (same thread as Update). Printable characters arrive - // via WM_CHAR (inserted at the caret). Backspace/Delete, arrow movement (with Ctrl for word skip), Home/End via. - // WM_KEYDOWN. A mouse click anywhere commits the edit (Enter/Escape are read from the game input in the HandleInput - // hook, which also blocks the menu from reacting). + // into the edit buffer. A mouse click anywhere commits the edit (Enter/Escape are read from the game input in the + // HandleInput hook, which also blocks the menu from reacting). static void on_wndproc(HWND, UINT msg, WPARAM wparam, LPARAM) { if (!g_editing) @@ -2055,7 +1965,7 @@ namespace big::mod_settings } } - // Registers on_wndproc with the framework's window hook the first time. It is needed + // Registers on_wndproc with the framework's window hook once editing needs typed input. static void ensure_wndproc_registered() { static bool registered = false; @@ -2128,9 +2038,8 @@ namespace big::mod_settings } // Resolves a localized string to the current game language: the entry for the current language code, then English, - // then the unlocalized value (empty key), then any entry. A plain (unlocalized) string is stored as the single - // empty-key entry and returned as-is. Returns "" when there is nothing to show. Resolution happens here (render - // time), so re-entering the tab after a language change picks up the new language. + // then the unlocalized value (empty key), then any entry. Resolution happens here (render time), so re-entering the + // tab after a language change picks up the new language. static std::string resolve_localized(const localized_text& t) { if (t.empty()) @@ -2162,10 +2071,7 @@ namespace big::mod_settings static bool g_view_has_dynamic = false; // A setting's metadata with any dynamic (Lua-function) description fields evaluated against the current game state. - // Identical to get_setting_metadata for static settings resolves live values (slider bounds, enum options, hidden, - // disabled, display name, ...) when the setting declares any function field. Must be called on the game thread - // while the Lua state is valid, as the menu build is live. Records that the view has a dynamic row so a later - // toggle can rebuild to re-evaluate it. + // Records that the view has a dynamic row so a later toggle can rebuild to re-evaluate it. static std::optional resolved_metadata(const std::string& stem, const std::string& section, const std::string& key) { auto meta = get_setting_metadata(stem, section, key); @@ -2204,8 +2110,7 @@ namespace big::mod_settings // Records or clears a restart-required setting change after the value has been written. If the new value equals the // session baseline (e.g. a toggle flipped and flipped back, or a number re-typed to its original), nothing actually - // changed, so the setting is dropped from the restart list otherwise. It is listed `new_value_display` is the value - // shown in the popup g_restart_required stays set as long as any real change remains. + // changed, so the setting is dropped from the restart list otherwise. static void note_change_if_restart_required(toml_v2::config_file::config_entry_base* entry, const std::string& new_value_display) { if (!entry || !entry->m_config_file) @@ -2236,10 +2141,17 @@ namespace big::mod_settings g_restart_required = !g_restart_changes.empty(); } + // The config section to use for a row's virtual-row Lua I/O (get/set/text). A `group` override can place a row on a + // menu page whose path differs from where its configDesc lives, so runtime lookups use the row's stored real + // config section, falling back to the current view path for rows built before that field was set. + static const std::string& row_io_section(const PanelRow* row) + { + return !row->config_section.empty() ? row->config_section : g_view_section; + } + // Commit helpers for an edited row: they write the config entry (with restart-required tracking) when the row is // config-backed, or call the interactive virtual row's Lua set() callback when it is virtual. Each returns true if - // the value actually changed, and arms the dynamic live-refresh so dependent rows re-evaluate. A virtual row is - // identified by (stem, current view section, key). + // the value actually changed, and arms the dynamic live-refresh so dependent rows re-evaluate. static bool commit_row_bool(PanelRow* row, bool v) { bool changed = false; @@ -2255,14 +2167,13 @@ namespace big::mod_settings } else if (row->is_virtual_input) { - const std::string& vsec = !row->config_section.empty() ? row->config_section : g_view_section; - const auto cur = get_virtual_value(row->stem, vsec, row->setting_key); + const auto cur = get_virtual_value(row->stem, row_io_section(row), row->setting_key); if (!(cur.type == virtual_value::kind::boolean && cur.as_bool == v)) { virtual_value nv; nv.type = virtual_value::kind::boolean; nv.as_bool = v; - set_virtual_value(row->stem, vsec, row->setting_key, nv); + set_virtual_value(row->stem, row_io_section(row), row->setting_key, nv); changed = true; } } @@ -2288,14 +2199,13 @@ namespace big::mod_settings } else if (row->is_virtual_input) { - const std::string& vsec = !row->config_section.empty() ? row->config_section : g_view_section; - const auto cur = get_virtual_value(row->stem, vsec, row->setting_key); + const auto cur = get_virtual_value(row->stem, row_io_section(row), row->setting_key); if (!(cur.type == virtual_value::kind::number && cur.as_number == v)) { virtual_value nv; nv.type = virtual_value::kind::number; nv.as_number = v; - set_virtual_value(row->stem, vsec, row->setting_key, nv); + set_virtual_value(row->stem, row_io_section(row), row->setting_key, nv); changed = true; } } @@ -2306,8 +2216,8 @@ namespace big::mod_settings return changed; } - // `serialized` is the config-serialized value (also the enum option's stored value). A config entry parses it back; - // a virtual set() receives it as a string (virtual enum options are matched/passed as strings). + // `serialized` is the config-serialized value (also the enum option's stored value). A config entry parses it back. + // A virtual set() receives it as a string (virtual enum options are matched/passed as strings). static bool commit_row_serialized(PanelRow* row, const std::string& serialized, const std::string& display) { bool changed = false; @@ -2323,14 +2233,13 @@ namespace big::mod_settings } else if (row->is_virtual_input) { - const std::string& vsec = !row->config_section.empty() ? row->config_section : g_view_section; - const auto cur = get_virtual_value(row->stem, vsec, row->setting_key); + const auto cur = get_virtual_value(row->stem, row_io_section(row), row->setting_key); if (!(cur.type == virtual_value::kind::string && cur.as_string == serialized)) { virtual_value nv; nv.type = virtual_value::kind::string; nv.as_string = serialized; - set_virtual_value(row->stem, vsec, row->setting_key, nv); + set_virtual_value(row->stem, row_io_section(row), row->setting_key, nv); changed = true; } } @@ -2370,10 +2279,8 @@ namespace big::mod_settings // number simply keeps the previous value. g_edit_entry->set_serialized_value(g_edit_buffer); - // Clamp/snap a bounded number typed via freetext to match what the stepper would produce: keep it - // within [min, max] and, if a step is declared, snap to the nearest grid point min + k*step (The native - // stepper enforces both freetext does it on commit.) set_serialized_value above already - // parsed/validated the number. + // Clamp/snap a bounded freetext number to the stepper grid: [min, max] and min + k*step. + // set_serialized_value above already parsed/validated the number. if (g_edit_entry->type() == typeid(double)) { const auto meta = resolved_metadata(g_edit_entry->m_config_file->m_config_file_stem_as_str, @@ -2410,11 +2317,9 @@ namespace big::mod_settings // If the author declared this setting restart-required, flag/clear the restart. note_change_if_restart_required(g_edit_entry, g_edit_entry->get_serialized_value()); - // Reflect the committed value in the right-hand display in place. Do NOT rebuild the panel here: a - // rebuild frees and recreates every row, which snaps the visible page back to the top while the - // scrollbar keeps the scrolled position, so the rows and the scrollbar desync until the next manual - // scroll. Only this one value changed, so just update its label (the native number-box rows persist the - // same in-place way). + // Reflect the committed value in the right-hand display in place. Do NOT rebuild the panel here: a rebuild frees + // and recreates every row, which snaps the visible page back to the top while the scrollbar keeps the scrolled + // position, so the rows and the scrollbar desync until the next manual scroll. refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); // Other rows may still key off this value (e.g. an apply button's dynamic `disabled`), so a dynamic @@ -2459,11 +2364,9 @@ namespace big::mod_settings return big::string::to_lower(key) == "enabled"; } - // True if a config entry carries an author-written description string. Chalk stores each configDesc entry's plain - // description directly on the bound entry (config:bind(section, key, value, description)), so this is how a - // Chalk-only mod (which never calls rom.mod_settings.load) signals that a key is described. Our own loader also - // writes the description here for string/`description`-field descs, and additionally records metadata-only descs - // in g_described_keys, so the two checks together recognize every configDesc form as "described". + // True if a config entry carries an author-written description string. Our own loader also writes the description here + // for string/`description`-field descs, and additionally records metadata-only descs in g_described_keys, so the two + // checks together recognize every configDesc form as "described". static bool entry_has_description(const toml_v2::config_file::config_entry_base* entry) { return entry && !entry->m_description.m_description.empty(); @@ -2474,33 +2377,27 @@ namespace big::mod_settings // Used to grey out context-restricted setting rows. static bool g_opened_in_game = false; - // True when the game global `CurrentHubRoom` is non-nil, i.e. the player is in the hub (the Crossroads) rather than - // in a run. Captured once in the ctor (see hook_MiscSettingsScreen_ctor) via game_is_in_hub() - the context cannot - // change while the pause screen is open. Combined with g_opened_in_game (a stale CurrentHubRoom at the main menu is - // then still safe) it gates `editableContext = "inHub"` rows. + // True when the game global `CurrentHubRoom` is non-nil, i.e. the player is in the hub (the Crossroads) rather than in + // a run. Captured once in the ctor (see hook_MiscSettingsScreen_ctor) via game_is_in_hub() - the context cannot change + // while the pause screen is open. static bool g_in_hub = false; - // True while a native options screen is open (set in the ctor,. Cleared when it actually closes in ExitScreen). - // Combined with g_opened_in_game it gates on_change callbacks so they fire only for a setting changed through the - // in-game options menu - never from the main menu, and never from a mod's own config write while no in-game options - // screen is open (which avoids a stale g_opened_in_game firing a callback in the main menu, and avoids - // double-applying a mod's own UI writes). + // True while a native options screen is open. Set in the ctor, cleared when it closes in ExitScreen. static bool g_options_screen_open = false; - // True while a setting change should notify its mod through an on_change callback: an options screen is currently - // open AND it was opened in-game (a save is loaded). This gates on_change so a callback fires only for an edit made - // through the in-game options menu that can be applied to the live run - never from the main menu, and never from a - // mod's own config write outside the menu. + // True while a setting change should notify its mod through an on_change callback: an options screen is currently open + // AND it was opened in-game (a save is loaded). This gates on_change so a callback fires only for an edit made through + // the in-game options menu that can be applied to the live run - never from the main menu, and never from a mod's own + // config write outside the menu. bool on_change_callbacks_enabled() { return g_options_screen_open && g_opened_in_game; } - // The MiscSettingsScreen ctor's "opened from" argument is the opening screen (sgg::MenuScreen*): a MainMenuScreen - // when opened from the main menu, a PauseScreen when opened in-game (the only two call sites in the engine). - // GameScreen::GetType (virtual, vtable slot 10 - a `mov eax,imm ret` stub, so calling. It is side-effect-free and - // ASLR-independent) returns the screen's ScreenType. Pause identifies the in-game opener. + // The MiscSettingsScreen ctor's "opened from" argument is the opening screen (sgg::MenuScreen*): a MainMenuScreen when + // opened from the main menu, a PauseScreen when opened in-game (the only two call sites in the engine). + // GameScreen::GetType (virtual, vtable slot 10 - a `mov eax,imm ret` stub, so calling. static constexpr std::size_t game_screen_get_type_vtable_slot = 10; static constexpr int screen_type_pause = 0x10'00'03; // sgg::ScreenType::Pause @@ -2556,11 +2453,9 @@ namespace big::mod_settings } } - // Description-box text for a context-restricted (editableContext-blocked) row: the scenario note on the first - // line(s), then the row's normal description below it, so the box explains BOTH why the row is read-only here and - // what it does. Either part may be empty (an empty note or description collapses to just the other). The break is a - // '\n', handled like the restart dialog's build_list_message; sync_description_box configures the box to honor it - // while keeping automatic word-wrap of each part. + // Description-box text for a context-restricted (editableContext-blocked) row: the scenario note on the first line(s), + // then the row's normal description below it, so the box explains BOTH why the row is read-only here and what it does. + // The break is a '\n', handled like the restart dialog's build_list_message. static std::string note_then_description(const std::string& note, const std::string& description) { if (note.empty()) @@ -2644,10 +2539,8 @@ namespace big::mod_settings return found; } - // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render - // as setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). Each direct child - // section renders as a group row that drills into it. At the root section a boolean "enabled" entry (if present) is - // pinned to the top when it is off, every other row is greyed out and made non-interactable. + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render as + // setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem, const std::string& section) { // A menu item is either a leaf setting directly in `section`, or a direct child group (a nested sub-section @@ -2658,7 +2551,7 @@ namespace big::mod_settings std::string key; // leaf key, or the group's last path segment toml_v2::config_file::config_entry_base* entry = nullptr; // leaf only std::string child_section; // group only (full menu path, e.g. "config.x.y") - std::string config_section; // the entry's REAL config section (for virtual I/O; group: its parent config section) + std::string config_section; // the entry's REAL config section (for virtual I/O - group: its parent config section) bool is_author_group = false; // group only: declared in configDesc `groups` (not a config section) localized_text author_name; // author-group display name (is_author_group only) localized_text author_description; // author-group description (is_author_group only) @@ -2716,9 +2609,9 @@ namespace big::mod_settings }; // Where an entry (living in config section `csection`, with an optional `group` override) sits relative to the - // current view `section`: 0 = not on this page (skip), 1 = a direct row here, 2 = inside a child group (its full - // menu path returned in child_out). The entry's menu path is its `group` override else its config section, so a - // flat config can be regrouped and a nested one re-nested without moving the actual config value. + // current view `section`: 0 = not on this page (skip), 1 = a direct row here, 2 = inside a child group (its full menu + // path returned in child_out). The entry's menu path is its `group` override else its config section, so a flat + // config can be regrouped and a nested one re-nested without moving the actual config value. auto placement = [&](const std::string& csection, const std::vector& group, std::string& child_out) -> int { const std::string m = resolve_menu_path(csection, group); @@ -2736,7 +2629,7 @@ namespace big::mod_settings }; // Creates (or ranks lower) the child group row at menu path `child_path`. A group declared in configDesc - // `groups` (find_author_group) takes its name/order/description from there; otherwise it is a config-derived + // `groups` (find_author_group) takes its name/order/description from there. Otherwise it is a config-derived // group whose metadata comes from its configDesc entry at the matching config section (resolved in the render). auto ensure_group = [&](const std::string& child_path, int app) { @@ -2796,11 +2689,9 @@ namespace big::mod_settings enabled_entry = entry.get(); } - // Hide config keys that carry no configDesc entry, so a mod's internal or bookkeeping values do not - // clutter its settings page. A key counts as described if it has metadata/a description from our loader - // (g_described_keys) or a plain description string bound by Chalk (entry_has_description). The one - // exception is the master "enabled" toggle, always shown so the mod stays toggleable even when its - // author did not describe it. + // Hide config keys that carry no configDesc entry, so a mod's internal or bookkeeping values do not clutter its + // settings page. The one exception is the master "enabled" toggle, always shown so the mod stays toggleable even + // when its author did not describe it. const bool is_enabled_toggle = key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key); if (!is_enabled_toggle && !setting_is_described(stem, key.m_section, key.m_key) && !entry_has_description(entry.get())) { @@ -2860,6 +2751,7 @@ namespace big::mod_settings it.config_section = a.section; it.has_order = a.has_order; it.order = a.order; + it.appearance = get_setting_appearance_order(stem, a.section, a.key); it.action = std::move(a); items.push_back(std::move(it)); } @@ -2957,7 +2849,7 @@ namespace big::mod_settings const std::string name = resolve_localized(it.action.name); const std::string label = escape_markup(name.empty() ? key_to_display(it.key) : name); - // A mod-off action is hard-disabled (block_input); an author-disabled or context-blocked action is only + // A mod-off action is hard-disabled (block_input). An author-disabled or context-blocked action is only // greyed (block_input=false), so it stays focusable/hoverable to show its note - clicks are still // blocked by pr.disabled in the OnClicked hook. This mirrors context-restricted settings. if (auto* row = make_button_row(screen, label.c_str(), act_disabled, /*block_input*/ mod_off)) @@ -2985,7 +2877,7 @@ namespace big::mod_settings pr.disabled = act_disabled; pr.target_section = it.action.section; // the section the action's callback lives in. - // A context mismatch shows the scenario note first, then the normal description below it; an + // A context mismatch shows the scenario note first, then the normal description below it. An // author-disabled action shows its disabledDescription (falling back to the normal description) so // the author can explain why it is greyed. if (ctx_blocked) @@ -3007,12 +2899,8 @@ namespace big::mod_settings continue; } - // A virtual row (config.lua `virtual = true`): a menu row whose value comes from Lua callbacks, not a - // config entry. A read-only row (`text`) renders as a greyed, focusable key + value row (like a - // context-restricted setting). An interactive row (`get`/`set`) renders a real widget - toggle/enum/ - // slider/number - inferred from get()'s value and the metadata, seeded from get() and committed via - // set(). There is no `hidden`: a virtual row has no backing state, so to omit it the author does not - // declare it. + // A virtual row (config.lua `virtual = true`): a menu row whose value comes from Lua callbacks, not a config entry. + // There is no `hidden`: a virtual row has no backing state, so to omit it the author does not declare it. if (it.is_virtual) { // A `group` override can move a virtual row onto a page whose path differs from its config section, so @@ -3023,12 +2911,8 @@ namespace big::mod_settings const std::string vlabel = escape_markup(!vname.empty() ? vname : key_to_display(it.key)); const std::string vdesc = vmeta ? resolve_localized(vmeta->description) : std::string{}; - // A read-only virtual (info) row, or an interactive row we cannot build a widget for (see below): a - // key + value row that just shows the display text. It looks NORMAL (not greyed - the value is - // available, only not editable) and stays mouse-hoverable so resting the pointer on it reveals its - // description (like a disabled row), but shows NO hover highlight and is skipped by keyboard/controller - // nav (pr.disabled clears mFreeFormSelectable), so neither cursor can select it. mIsUseable is left on - // (make_text_row disabled=false) so the mouse can still resolve it for the description. + // A read-only virtual row, or an interactive row with no widget, becomes key + value text. mIsUseable + // stays on so the mouse can still resolve it for the description. const auto build_readonly = [&](const std::string& value_text) { if (auto* row = make_text_row(screen, vlabel.c_str(), /*disabled*/ false, /*block_input*/ false, /*no_hover_highlight*/ true)) @@ -3048,10 +2932,8 @@ namespace big::mod_settings continue; } - // Interactive row. Infer the widget from get()'s value type plus the metadata, exactly like a config - // setting is inferred from its config value type. When get() returns nil at build time (the mod's state - // is not ready yet), the author can force the widget with `type` - synthesize a starting value from - // `default` (or a sensible fallback) so the widget still builds instead of falling back to read-only. + // Interactive row. If get() returns nil, `type` can force a widget. Seed it from `default` or a + // fallback so it still builds. virtual_value vv = get_virtual_value(stem, vsection, it.key); if (vv.type == virtual_value::kind::none && vmeta && vmeta->type != widget_type::inferred) { @@ -3243,7 +3125,7 @@ namespace big::mod_settings } // A nested group drills into its child menu path when clicked/activated. A config-derived group takes its - // display name/description from its configDesc entry (its menu path equals its config section); an author + // display name/description from its configDesc entry (its menu path equals its config section). An author // group (configDesc `groups`) carries its own name/description captured during collection. if (it.is_group) { @@ -3259,10 +3141,8 @@ namespace big::mod_settings { auto gmeta = resolved_metadata(stem, section, it.key); - // A group's desc table doubles as its children's descriptions, so a group-consumed field - // (displayName/description/hidden) that is actually one of the group's own config children belongs - // to that child, not the group. Defer to the child so the two never collide. `order` is deferred - // the same way during collection above. + // A group's desc table doubles as its children's descriptions. If displayName/description/hidden is + // one of the group's own config children, defer to that child. if (gmeta && view_cfg) { if (config_child_exists(view_cfg, it.child_section, "displayName")) @@ -3309,19 +3189,17 @@ namespace big::mod_settings continue; } - // An author may mark a setting `disabled` (statically or via a dynamic function): the row stays visible but - // is shown read-only and greyed (e.g. a cap that only applies while its parent fix is on). Rendered through - // the same greyed read-only text+value path as a context-restricted row, which reads as clearly greyed (a - // disabled slider/toggle keeps its bright graphic and does not). Distinct from the mod-disabled greying - // (whole panel off), which keeps the native widgets. + // An author may mark a setting `disabled` (statically or via a dynamic function): the row stays visible but is shown + // read-only and greyed (e.g. a cap that only applies while its parent fix is on). Distinct from the mod-disabled + // greying (whole panel off), which keeps the native widgets. const bool author_disabled = meta && meta->disabled; const std::string mname = meta ? resolve_localized(meta->name) : std::string{}; const std::string label = escape_markup(!mname.empty() ? mname : key_to_display(key)); // An enum (metadata `values`) renders as a native number box cycling its label list. A numeric setting with - // author-declared min AND max renders as a native number box over its range (like the FPS-limit option) - // UNLESS the author set `freetext` (e.g. for a very large range better typed than stepped) other numbers - // stay freetext-editable with a plain right-column value label. + // author-declared min AND max renders as a native number box over its range (like the FPS-limit option) UNLESS the + // author set `freetext` (e.g. for a very large range better typed than stepped) other numbers stay freetext-editable + // with a plain right-column value label. const bool is_number = entry->type() == typeid(double); const bool is_enum = meta && !meta->values.empty(); const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max && !meta->freetext; @@ -3364,18 +3242,15 @@ namespace big::mod_settings GUIComponent* value = nullptr; bool built_slider = false; - // A setting that is unavailable in the current context (editable_context mismatch) or that the author - // marked `disabled` is shown read-only: its current value in a greyed key+value row that still takes focus, - // so the description box can explain why. Edits are blocked by pr.disabled in the row handlers. Skipped - // when the whole mod is disabled, whose own greying already covers every row with the native widgets. + // A setting that is unavailable in the current context (editable_context mismatch) or that the author marked + // `disabled` is shown read-only: its current value in a greyed key+value row that still takes focus, so the + // description box can explain why. Edits are blocked by pr.disabled in the row handlers. const editable_context ctx = effective_editable_context(meta, is_enabled_row); const bool context_blocked = is_context_restricted(ctx); if (!disabled && (context_blocked || author_disabled)) { - // Greyed but still visible: the setting keeps its real widget (toggle/enum cycler/slider), greyed - // and focusable so the description box can explain why it is unavailable, with edits blocked by - // pr.disabled in the row handlers. Only a plain string falls back to a greyed key + value text row. - // block_input=false keeps each widget selectable (hoverable for its note) while still greyed. + // Greyed but still visible: keep the real widget focusable so the description can explain why it is + // unavailable. Edits are blocked by pr.disabled. GUIComponent* ro_row = nullptr; GUIComponent* ro_value = nullptr; bool ro_is_toggle = false; @@ -3443,7 +3318,7 @@ namespace big::mod_settings pr.value_component = ro_value; } - // A context mismatch shows the scenario note first, then the normal description below it; an + // A context mismatch shows the scenario note first, then the normal description below it. An // author-disabled row shows its disabledDescription (falling back to the normal description) so the // author can explain why it is greyed. if (context_blocked) @@ -3531,13 +3406,11 @@ namespace big::mod_settings } } - // Matches the native category-switch transition: the incoming page fades in and there is no fade-out crossover. - // Native UpdateScrollState sets each on-page row's mFadeTarget to 1 and each off-page row's to 0, and - // GUIComponent::Update (driven by MenuScreen::Update, which the original runs before this) eases mFadeOpacity - // toward the target at dt * mFadeSpeed - so on-page rows are left entirely to the native ease. We only force - // off-page rows fully transparent so a row leaving the page vanishes at once instead of fading out on top of the - // incoming page. Rows are in m_options/g_rows order, so row i is on the current page when start <= i < start + - // rows_per_page. + // Matches the native category-switch transition: the incoming page fades in and there is no fade-out crossover. Native + // UpdateScrollState sets each on-page row's mFadeTarget to 1 and each off-page row's to 0, and GUIComponent::Update + // (driven by MenuScreen::Update, which the original runs before this) eases mFadeOpacity toward the target at dt * + // mFadeSpeed - so on-page rows are left entirely to the native ease. Rows are in m_options/g_rows order, so row i is + // on the current page when start <= i < start + rows_per_page. static void sync_scroll_fade(MiscSettingsScreen* screen) { const std::size_t first = screen->m_page_start_index; @@ -3600,9 +3473,23 @@ namespace big::mod_settings } // Builds a stable identity for a row so it can be re-found after a rebuild recreates the components. + // A row's real config section, used to tell same-named keys apart when a `group` override moves them onto one page. + static std::string row_config_section_of(const PanelRow& r) + { + if (r.entry) + { + return r.entry->m_definition.m_section; + } + if (!r.config_section.empty()) + { + return r.config_section; + } + return r.target_section; + } + static RowIdentity row_identity_of(const PanelRow& r) { - return RowIdentity{true, r.kind, r.stem, r.target_section, r.setting_key}; + return RowIdentity{true, r.kind, r.stem, r.target_section, r.setting_key, row_config_section_of(r)}; } // The freshly built row matching a captured identity, or null if it is gone or is no longer selectable. Used to put @@ -3616,7 +3503,7 @@ namespace big::mod_settings for (const auto& row : g_rows) { GUIComponent* c = row.component; - if (c && row.kind == id.kind && row.stem == id.stem && row.target_section == id.section && row.setting_key == id.key && !row.disabled && c->m_is_useable && !c->m_hidden) + if (c && row.kind == id.kind && row.stem == id.stem && row.target_section == id.section && row.setting_key == id.key && row_config_section_of(row) == id.config_section && !row.disabled && c->m_is_useable && !c->m_hidden) { return c; } @@ -3625,11 +3512,8 @@ namespace big::mod_settings } // True while the user is still actively adjusting one of our rows: the entered component (keyboard or controller - // adjusting a slider/enum), or a mouse drag (a mouse button held over one of our rows). The numeric-change dynamic - // refresh holds its rebuild until this is false, so the rebuild never frees a row mid-adjust (which would drop - // keyboard focus or interrupt a mouse drag). A mere mouse hover does not hold, so the refresh fires promptly once a - // drag is released even while the pointer still rests on the row. If the mouse-down probe is unavailable, any hover - // holds instead, so a drag is never interrupted. + // adjusting a slider/enum), or a mouse drag (a mouse button held over one of our rows). If the mouse-down probe is + // unavailable, any hover holds instead, so a drag is never interrupted. static bool interacting_with_row(MiscSettingsScreen* screen, void* input) { if (screen->m_component_focused && find_row(screen->m_component_focused)) @@ -3649,9 +3533,7 @@ namespace big::mod_settings static GUIComponent* g_last_description_component = nullptr; // Shows the highlighted row's author description in the screen's native description box - // (MiscSettingsScreen::mDescriptionBox @ 0x460). The highlighted component is the mouse-over one (mouse) or the - // selected one (keyboard/controller). If it is one of our rows, its description is shown as raw text. Otherwise the - // box is cleared. + // (MiscSettingsScreen::mDescriptionBox @ 0x460). Otherwise the box is cleared. static void sync_description_box(MiscSettingsScreen* screen) { if (!g_show_text || !screen->m_description_box) @@ -3675,12 +3557,10 @@ namespace big::mod_settings { g_last_description_component = active; - // Escape markup so paths/brackets in the description render verbatim (see escape_markup), then turn any - // embedded newline into the box's hard-break escape. GUIComponentTextBox::Parse strips a raw 0x0A but - // honors the escape "\n" (backslash + n) as a wrap-independent hard break (via ParseEscapeSequence), so a - // context note stays on its own line above the description while each part still word-wraps. The escape is - // inserted AFTER escape_markup, which would otherwise double its backslash into a literal "\n". Padded - // " \n " like the engine's own AddLineBreak so the surrounding whitespace is eaten cleanly. + // Escape markup so paths/brackets in the description render verbatim (see escape_markup), then turn any embedded + // newline into the box's hard-break escape. GUIComponentTextBox::Parse strips a raw 0x0A but honors the escape "\n" + // (backslash + n) as a wrap-independent hard break (via ParseEscapeSequence), so a context note stays on its own + // line above the description while each part still word-wraps. std::string shown; if (show) { @@ -3726,10 +3606,8 @@ namespace big::mod_settings g_set_label(button, text); } - // Retunes the options screen's bottom button prompts for the Mods tab per context, and hides the native Reset - // prompt where it must not apply. Called every frame from the Update hook (after the original, which sets the - // native prompts on focus/hover/category events). Off the Mods tab it only clears our caches and leaves the native - // prompts untouched. + // Retunes the options screen's bottom button prompts for the Mods tab per context, and hides the native Reset prompt + // where it must not apply. Off the Mods tab it only clears our caches and leaves the native prompts untouched. static void sync_prompts(MiscSettingsScreen* screen, bool on_mods_tab) { if (!on_mods_tab) @@ -3742,10 +3620,8 @@ namespace big::mod_settings auto* menu = reinterpret_cast(screen); // The native prompt strings embed a glyph token that the text box expands to the device- appropriate key icon:. - // "{CN}" = the Cancel control (Esc/B), "{SL}" = the Select/Confirm control (Enter/A). We prepend the same - // token to our custom labels so the icon is kept (a raw string with no token renders text only). Labels are - // upper-case to match the game Cancel (Esc): "CANCEL" while editing a field "BACK" inside a mod's settings (Esc - // returns to the mod list, see the ExitScreen hook) "EXIT" at the mod list (closes the options screen). + // Labels are upper-case to match the game Cancel (Esc): "CANCEL" while editing a field "BACK" inside a mod's settings + // (Esc returns to the mod list, see the ExitScreen hook) "EXIT" at the mod list (closes the options screen). const char* cancel = g_editing ? "{CN} CANCEL" : (g_view == View::mod_settings ? "{CN} BACK" : "{CN} EXIT"); set_prompt_label(menu->m_cancel_button, g_prompt_cancel_label, cancel); @@ -3796,10 +3672,10 @@ namespace big::mod_settings } } - // Drive the Confirm prompt's visibility ourselves: native only fades it in (OnOptionMouseOver) for its OWN - // option rows, which never fires for our custom rows Show it with its glyph whenever we have a hint, hide it - // when we don't mFadeOpacity is the field the draw gate reads native Update rewrites mHidden each frame, so - // both are set here (after the original Update). + // Drive the Confirm prompt's visibility ourselves: native only fades it in (OnOptionMouseOver) for its OWN option + // rows, which never fires for our custom rows Show it with its glyph whenever we have a hint, hide it when we don't + // mFadeOpacity is the field the draw gate reads native Update rewrites mHidden each frame, so both are set here + // (after the original Update). if (menu->m_confirm_button) { if (confirm.empty()) @@ -3828,12 +3704,10 @@ namespace big::mod_settings // Focuses the first selectable row so the controller/keyboard cursor lands on it, as a native category does when // shown. The engine's DoShowCategory teleports the free-form cursor onto mOptions[0] and clears mCategoryFocused - // (switching from tab to option navigation) only when the option list is already populated at that point our rows - // are appended afterwards, so it is skipped - leaving the screen in tab-navigation mode, which is why the stick - // never reaches the rows (no highlight, sliders ignore left/right) until the tab is selected a second time. Mouse - // mode is left untouched (the mouse drives hover itself, teleporting would yank the pointer). Drops the - // controller/keyboard cursor onto a specific row so the next Update focuses it (green + stick input). No-op in - // mouse mode (the mouse drives hover). The row must be selectable. + // (switching from tab to option navigation) only when the option list is already populated at that point our rows are + // appended afterwards, so it is skipped - leaving the screen in tab-navigation mode, which is why the stick never + // reaches the rows (no highlight, sliders ignore left/right) until the tab is selected a second time. The row must be + // selectable. static void focus_row(MiscSettingsScreen* screen, GUIComponent* component) { if (!g_teleport_cursor || (g_use_mouse && *g_use_mouse) || !component) @@ -3861,14 +3735,11 @@ namespace big::mod_settings } } - // After a native page scroll (the on-screen arrow's auto-activate fires MiscSettingsScreen::ScrollDown/ScrollUp), - // the engine selects the new page's edge row directly - mOptions[pageStart] going down, the last on-page row going - // up - via SetMouseOver plus a free-form cursor teleport, without consulting mFreeFormSelectable. So when that edge - // row is disabled the cursor lands on it (a hidden highlight on a greyed row) instead of the first interactable - // row. This runs right after the native handler when the page changed under keyboard/controller: it finds the - // first (going down) or last (going up) eligible row on the new page and moves the highlight and cursor there. If - // every row on the page is disabled it leaves the native edge selection as a fallback. Mouse mode is not touched - // (the pointer drives hover itself). + // After a native page scroll (the on-screen arrow's auto-activate fires MiscSettingsScreen::ScrollDown/ScrollUp), the + // engine selects the new page's edge row directly - mOptions[pageStart] going down, the last on-page row going up - + // via SetMouseOver plus a free-form cursor teleport, without consulting mFreeFormSelectable. If every row on the page + // is disabled it leaves the native edge selection as a fallback. Mouse mode is not touched (the pointer drives hover + // itself). static void redirect_page_landing(MiscSettingsScreen* screen, bool going_down) { if (!g_set_mouse_over || !g_teleport_cursor || (g_use_mouse && *g_use_mouse) || g_rows.empty()) @@ -3975,12 +3846,11 @@ namespace big::mod_settings return (g_input_get_state(input, control) & 0x4u) != 0; } - // Holds the clicked row (captured before a click-triggered instant rebuild) as the moused-over and selected - // component, and forces our bottom prompt and description to re-apply, for a few frames after the rebuild. The - // native hover pass runs in HandleInput (after this Update) and, over the freshly laid-out rows, can transiently - // resolve the stationary cursor to a neighbouring row or clear the prompt label, so re-asserting here each frame - // keeps the prompt, description and highlight steady on the clicked row instead of blinking onto a neighbour or to - // a bare glyph. Mouse mode only - keyboard/controller focus is restored in build_panel. + // Holds the clicked row (captured before a click-triggered instant rebuild) as the moused-over and selected component, + // and forces our bottom prompt and description to re-apply, for a few frames after the rebuild. The native hover pass + // runs in HandleInput (after this Update) and, over the freshly laid-out rows, can transiently resolve the stationary + // cursor to a neighbouring row or clear the prompt label, so re-asserting here each frame keeps the prompt, + // description and highlight steady on the clicked row instead of blinking onto a neighbour or to a bare glyph. static void reassert_keep_active_row(MiscSettingsScreen* screen) { if (!(g_use_mouse && *g_use_mouse)) @@ -4015,8 +3885,7 @@ namespace big::mod_settings // Moves a row (and its owned child components) to an absolute location via the engine's own SetLocation (GUIComponent // vtable slot +0x180) - the same call UpdateScrollState uses to lay rows on the grid. Going through SetLocation // (rather than writing m_location_y directly) keeps a row's children - a slider's bar/label, a button's label - in - // step, avoiding the per-frame drift a raw location write causes. The native call passes the Vector2 packed in one - // 64-bit register (y in the high half, x in the low), which we reproduce here. + // step, avoiding the per-frame drift a raw location write causes. static void set_component_location(GUIComponent* comp, float x, float y) { char* vtable = *reinterpret_cast(comp); @@ -4027,20 +3896,12 @@ namespace big::mod_settings fn(comp, (static_cast(yb) << 32) | xb); } - // Reverts a stale highlight left on the wrong slider or num-box row. Unlike a button, these have no Draw-time - // highlight gate: their lit look is child state set by an OnXxxOn handler and undone only by the matching OnXxxOff, - // never re-derived in Draw. A rebuild or our hover re-assert (which writes mMouseOverComponent directly, bypassing - // the native OnMouseOver/OnMouseOff pairing) can strand that state on a row the cursor has since left, leaving it - // stuck lit until hovered again. Each frame we revert it on any such row that is not the live mouse-over/focused - // component, so exactly the active row stays highlighted. - // - // Slider and num-box differ in WHICH handler sets the look: a slider's moused-over look (green label + bright fill) - // is set by OnMouseOver and reverted by OnMouseOff (vtbl+0x60); a num-box's lit look (black box + green label) is - // set by OnSelected and reverted by OnUnselected (vtbl+0x88) - its OnMouseOff is an inherited no-op. The num-box - // OnSelected look fires under keyboard/controller nav too (not just mouse), so its revert is gated to mouse mode to - // avoid clearing a genuine gamepad selection; the slider moused-over flag is only ever set in mouse mode, so its - // revert needs no such gate. Both also carry a focus look (green value + mFocused) reverted by OnFocusOff - // (vtbl+0x118). Buttons and text rows self-correct via their own Draw gate, so only these two widgets need this. + // Reverts a stale highlight left on the wrong slider or num-box row. Slider and num-box differ in WHICH handler sets + // the look: a slider's moused-over look (green label + bright fill) is set by OnMouseOver and reverted by OnMouseOff + // (vtbl+0x60). A num-box's lit look (black box + green label) is set by OnSelected and reverted by OnUnselected + // (vtbl+0x88) - its OnMouseOff is an inherited no-op. The num-box OnSelected look fires under keyboard/controller nav + // too (not just mouse), so its revert is gated to mouse mode to avoid clearing a genuine gamepad selection. Both also + // carry a focus look (green value + mFocused) reverted by OnFocusOff (vtbl+0x118). static void clear_stale_widget_highlight(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); @@ -4089,13 +3950,10 @@ namespace big::mod_settings } } - // Keeps a greyed-but-still-selectable widget row's label (and value) text greyed. Such a row keeps mIsUseable=1 so - // the mouse can still hover it for its note, but a widget's Draw writes the label text box's colour flags from - // mIsUseable every frame, clearing the disabled flag so the label falls back to its cached bright mTextColor (set - // once from the bright template def at build - greying the def afterwards does not update the cached value). We - // re-apply the grey through the text box's own SetTextColor each frame (the same call the engine's - // UpdateButtonStates uses to grey a still-hoverable option), which writes that cached mTextColor directly. The - // selected-colour def is greyed too (grey_text_box) so a hover that briefly sets the selected flag stays grey. + // Keeps a greyed-but-still-selectable widget row's label (and value) text greyed. We re-apply the grey through the + // text box's own SetTextColor each frame (the same call the engine's UpdateButtonStates uses to grey a still-hoverable + // option), which writes that cached mTextColor directly. The selected-colour def is greyed too (grey_text_box) so a + // hover that briefly sets the selected flag stays grey. static void keep_disabled_labels_grey() { const auto grey_label = [](char* base, std::size_t tb_offset) @@ -4134,12 +3992,11 @@ namespace big::mod_settings } // Makes the keyboard/controller spatial nav skip every disabled/greyed row so DOWN/UP jumps straight to the next - // interactable one (with the native wrap and cross-page paging), while leaving mouse hover untouched so a mouse - // user can still rest on a greyed row to read its description. It clears mData.mDef.mFreeFormSelectable on each - // disabled row and the paired value-display column - the only gate SearchInDirection checks before IsSelectable, - // and one MenuScreen::UpdateMouseOver never reads. Interactable rows keep the template default (selectable). Called - // after every build: the row objects are recreated each time, so a fresh build restores the default before this - // reapplies it. + // interactable one (with the native wrap and cross-page paging), while leaving mouse hover untouched so a mouse user + // can still rest on a greyed row to read its description. It clears mData.mDef.mFreeFormSelectable on each disabled + // row and the paired value-display column - the only gate SearchInDirection checks before IsSelectable, and one + // MenuScreen::UpdateMouseOver never reads. Called after every build: the row objects are recreated each time, so a + // fresh build restores the default before this reapplies it. static void apply_row_freeform_selectability() { for (const auto& row : g_rows) @@ -4216,10 +4073,8 @@ namespace big::mod_settings build_mod_list(screen); } - // Let the engine position, paginate and drive the scrollbar/arrows for the rows. Backing out to a parent view - // (the mod list, or a parent section) is a real view change (not instant), which would otherwise snap to the - // top. If a restore is pending from the back-nav, restore that view's saved scroll offset so the user lands - // where they were. + // Let the engine position, paginate and drive the scrollbar/arrows for the rows. If a restore is pending from the + // back-nav, restore that view's saved scroll offset so the user lands where they were. const bool restoring = !instant && g_has_pending_restore; std::uint32_t start = 0; @@ -4255,8 +4110,7 @@ namespace big::mod_settings // A view change leaves the freshly built rows at mFadeOpacity 0 (finalize_row). The native ease // (GUIComponent::Update) then fades the on-page rows in toward mFadeTarget == 1, matching the game's own - // category-switch transition. Off-page rows are held transparent in sync_scroll_fade. Value displays are not - // laid out by the scroll pass place them on their key rows now. + // category-switch transition. sync_value_columns(); // Take the disabled/greyed rows out of the keyboard/controller nav so the cursor only lands on interactable @@ -4264,9 +4118,8 @@ namespace big::mod_settings apply_row_freeform_selectability(); // On a real view change (tab entry, drilling in, going back), drop the cursor on the first row so it highlights - // immediately like a native category. Skipped on in-place refreshes so committing an edit or toggling "enabled" - // does not yank focus back to the top. When backing out, focus the row the user drilled through (the mod in the - // list, or the group in its parent section) rather than the first row. + // immediately like a native category. Skipped on in-place refreshes so committing an edit or toggling "enabled" does + // not yank focus back to the top. if (!instant) { GUIComponent* restore_focus = restoring ? restore_target_row(g_pending_restore) : nullptr; @@ -4294,11 +4147,8 @@ namespace big::mod_settings } } - // Arm a short re-assert window after a click-triggered instant rebuild (a toggle or an action). A rebuild frees - // the row under the cursor, and over the next frame the native hover pass can transiently resolve the - // stationary cursor to a neighbouring row or clear our prompt label, blinking the prompt, description and - // highlight. The Update hook re-asserts the clicked row over these frames (see reassert_keep_active_row). Only - // meaningful in mouse mode - keyboard/controller focus is restored above. + // Arm a short re-assert window after a click-triggered instant rebuild (a toggle or an action). The Update hook + // re-asserts the clicked row over these frames (see reassert_keep_active_row). if (instant && g_keep_active_row.valid && g_use_mouse && *g_use_mouse) { g_keep_active_frames = keep_active_frame_count; @@ -4315,21 +4165,15 @@ namespace big::mod_settings } } - // Applies a queued navigation (mod list <-> a mod's settings) by rebuilding the panel. Called from the Update hook, - // i.e. outside click/input iteration, where mutating the component vectors is safe. A rebuild that stays on the - // same view/mod (e.g. after toggling "enabled") is applied instantly to avoid a fade flash. A real view change - // keeps the fade-in transition. + // Applies a queued navigation (mod list <-> a mod's settings) by rebuilding the panel. A rebuild that stays on the + // same view/mod (e.g. after toggling "enabled") is applied instantly to avoid a fade flash. static void apply_nav(MiscSettingsScreen* screen) { // A rebuild that stays on the same view/mod/section (a setting edit, an "enabled" toggle, or a Reset) is // applied instantly, which preserves the current scroll page instead of snapping back to the top. const bool instant = (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); - // Maintain the restore stack. A drill-in step (the mod list into a mod, or a section into a deeper child - // section) pushes the parent's scroll offset plus the identity of the row being drilled through A back step (a - // mod out to the list, or a child section out to its parent) pops that entry for build_panel to restore g_view - // is still the parent view here, so m_page_start_index is the parent's own scroll offset. A same-view rebuild - // (instant:. Reset or an "enabled" toggle) is neither, so it leaves the stack untouched. + // Maintain the restore stack. A same-view rebuild (instant:. const bool drilling_in = (g_view == View::mod_list && g_pending_view == View::mod_settings) || (g_view == View::mod_settings && g_pending_view == View::mod_settings && g_pending_section.rfind(g_view_section + ".", 0) == 0); @@ -4365,10 +4209,8 @@ namespace big::mod_settings } // The serialized default of a config entry, read from the entry itself via the public write_description (whose last - // output line is "#. Default value: "). Works for any entry regardless of who bound it, so it recovers - // defaults for Chalk-bound mods, which never went through rom.mod_settings.load and so have no captured default in - // get_setting_default. The serialized form uses the same converter as get_serialized_value, so it round-trips - // through set_serialized_value. + // output line is "#. The serialized form uses the same converter as get_serialized_value, so it round-trips through + // set_serialized_value. static std::optional entry_default_serialized(toml_v2::config_file::config_entry_base* entry) { if (!entry) @@ -4388,10 +4230,8 @@ namespace big::mod_settings } // Restores the current mod's config entries (g_view_stem) to their defaults, saving each change and flagging any - // restart-required ones. Only ever resets the one mod whose settings are open - never every mod - so it is called - // only from the mod-settings view. The default comes from the config.lua value captured by rom.mod_settings.load - // when available, and otherwise from the config entry's own stored default (so. Chalk-bound mods, which never go - // through load, still reset). Returns true if any value actually changed. + // restart-required ones. The default comes from the config.lua value captured by rom.mod_settings.load when available, + // and otherwise from the config entry's own stored default (so. static bool reset_settings_to_defaults() { bool any_changed = false; @@ -4446,11 +4286,9 @@ namespace big::mod_settings return any_changed; } - // Handles a Reset activation on the Mods tab: restores the in-scope settings to their config.lua defaults, then (in - // a mod's settings view, where the changed values are on screen) queues an in-place rebuild so the widgets show the - // restored values. The rebuild is instant (same view/mod/section), so it preserves the current scroll page and a - // Reset never jumps back to the first page. Safe to call from input/click context because the rebuild is deferred - // to the Update hook. + // Handles a Reset activation on the Mods tab: restores the in-scope settings to their config.lua defaults, then (in a + // mod's settings view, where the changed values are on screen) queues an in-place rebuild so the widgets show the + // restored values. Safe to call from input/click context because the rebuild is deferred to the Update hook. static void perform_reset() { const bool changed = reset_settings_to_defaults(); @@ -4463,23 +4301,18 @@ namespace big::mod_settings } } - // True when the game's current display language uses a CJK font (zh-CN, zh-TW, ja, ko). Those fonts have no glyph - // for the non-breaking space U+00A0 and draw a visible '*' instead, so the restart message uses regular spaces and - // a U+3000 blank for them. Every other language uses a Latin/Cyrillic/Greek font that renders U+00A0 invisibly - - // which is needed there to keep the (English) mod/setting entries from wrapping mid-line. + // True when the game's current display language uses a CJK font (zh-CN, zh-TW, ja, ko). Those fonts have no glyph for + // the non-breaking space U+00A0 and draw a visible '*' instead, so the restart message uses regular spaces and a + // U+3000 blank for them. static bool current_language_is_cjk() { const std::string code = current_language_code(); return code.rfind("zh", 0) == 0 || code.rfind("ja", 0) == 0 || code.rfind("ko", 0) == 0; } - // Builds a locale-aware popup body: an intro line, a blank line, one line per list entry, a blank line, then an - // outro line (plus a sacrificial trailing blank). The character choices depend on the current locale's font (see - // current_language_is_cjk): CJK locales use regular spaces and a U+3000 ideographic-space blank line all others use - // non-breaking spaces (U+00A0), which keep each intro/entry/outro line whole under the width-greedy formatter and - // double as the blank line. Both blank characters survive ShowText's ASCII-whitespace-line trim. The trailing blank - // is sacrificial because the formatter also trims the last whitespace-only line. Shared by the restart-required and - // dependency-block dialogs. + // Builds a locale-aware popup body: an intro line, a blank line, one line per list entry, a blank line, then an outro + // line (plus a sacrificial trailing blank). Both blank characters survive ShowText's ASCII-whitespace-line trim. + // Shared by the restart-required and dependency-block dialogs. static std::string build_list_message(const std::string& intro, const std::vector& lines, const std::string& outro) { const bool cjk = current_language_is_cjk(); @@ -4543,11 +4376,7 @@ namespace big::mod_settings // Persists the game's native Options settings (language, audio volumes, resolution/window/graphics, and all // gameplay/interface/accessibility toggles) to disk. The engine normally does this only when the options screen // finishes closing (MiscSettingsScreen::OnExit -> ProfileManager::SaveProfile), which never runs when we force a - // restart. So any native settings the player changed earlier in the same options session would be lost. Call this - // immediately before terminating the process, using. SaveProfile's synchronous path (async=false, no save spinner) - // so the files are written before we exit. Keybinds are excluded on purpose: they are saved separately when the - // Controls sub-screen closes, so they are already on disk by the time the player is back on the main options - // screen. + // restart. SaveProfile's synchronous path (async=false, no save spinner) so the files are written before we exit. static void flush_native_settings() { if (g_save_profile && g_active_profile) @@ -4558,19 +4387,15 @@ namespace big::mod_settings // Shows the native single-button message box (sgg::MessageDialog, the same box the game uses in the main menu for // save/file. Errors), modal over the options screen, with `title` as the heading and `message` as the body. When - // confirm_closes_game is true the confirm button is captured so the OnClicked hook closes the game on press (used - // for a forced restart, which must not be cancellable) otherwise the button keeps its native behaviour and simply - // dismisses the dialog (used for informational prompts). Returns true if the dialog was shown. Returns false only - // if it could not be built (no screen manager or allocation failure). The dialog machinery is derived off the - // verified build anchor, so a mismatched game build disables the whole tab up front rather than reaching here. + // confirm_closes_game is true the confirm button is captured so the OnClicked hook closes the game on press (used for + // a forced restart, which must not be cancellable) otherwise the button keeps its native behaviour and simply + // dismisses the dialog (used for informational prompts). static bool show_message_dialog(void* screen_manager, const char* title, const std::string& message, bool confirm_closes_game) { if (screen_manager && g_message_dialog_ctor && g_add_screen) { - // The game's ScreenManager owns and frees this screen (with _aligned_free) once. It is dismissed H2M's - // static /MT UCRT and the game's ucrtbase share the process heap, so this. _aligned_malloc pairs safely - // with the game's _aligned_free - the same alloc/free split the num-box rows rely on (game factory - // allocates, destroy_rows frees). + // The game's ScreenManager owns and frees this screen (with _aligned_free) once. It is dismissed H2M's static /MT + // UCRT and the game's ucrtbase share the process heap, so this. void* dialog = _aligned_malloc(message_dialog_size, 8); if (dialog) { @@ -4670,9 +4495,8 @@ namespace big::mod_settings } // Display names of the currently-enabled loaded mods that declare `stem` as a dependency (via their Thunderstore - // manifest, which lists dependency guids in dependencies_no_version_number). Disabling `stem` while any of these is - // enabled would break them, so the menu blocks it. A dependent that is itself disabled is skipped -. It is not - // relying on `stem` right now. Sorted for a stable list. + // manifest, which lists dependency guids in dependencies_no_version_number). A dependent that is itself disabled is + // skipped -. static std::vector active_dependents_of(const std::string& stem) { std::vector result; @@ -4702,10 +4526,9 @@ namespace big::mod_settings return result; } - // Body text for the dependency-block popup: lists the enabled mods depending on the one the player tried to - // disable, and tells them how to proceed. The intro/outro are kept short so they fit the dialog width on every - // locale (the wider CJK fonts overflow a long line). The blocked mod is identified by the dialog title and the - // toggle the player just clicked, so it is not repeated here. + // Body text for the dependency-block popup: lists the enabled mods depending on the one the player tried to disable, + // and tells them how to proceed. The blocked mod is identified by the dialog title and the toggle the player just + // clicked, so it is not repeated here. static std::string build_dependency_message(const std::vector& dependents) { return build_list_message("These enabled mods depend on this one:", dependents, "Disable them first to disable this mod."); @@ -4735,10 +4558,9 @@ namespace big::mod_settings g_prompt_cancel_label.clear(); exit_edit_mode(); - // Record whether the screen was opened during gameplay (a save loaded) or from the main menu, so - // context-restricted rows can be greyed. Must be set before the original ctor runs, which shows the last-viewed - // category and may build our panel via DoShowCategory. game_is_in_hub() further distinguishes the hub (the - // Crossroads) from a run for `editableContext = "inHub"` rows. + // Record whether the screen was opened during gameplay (a save loaded) or from the main menu, so context-restricted + // rows can be greyed. Must be set before the original ctor runs, which shows the last-viewed category and may build + // our panel via DoShowCategory. g_opened_in_game = opener_indicates_in_game(opened_from); g_in_hub = game_is_in_hub(); g_options_screen_open = true; @@ -4761,13 +4583,10 @@ namespace big::mod_settings auto* screen = static_cast(self); const bool is_mods_tab = category_button && category_button == reinterpret_cast(screen->m_editor_options_button); - // Leaving the Mods tab for another category: tear our rows down FIRST, before the native category switch runs. - // The native switch only unlinks the outgoing category's mOptions entries from mComponents our right-column - // value components are in mComponents but NOT mOptions (they are drawn, not paged), so the native teardown - // would leave them behind. They would then linger in mComponents on the other category - re-localized by a - // language change and walked by the native layout - which can corrupt unrelated widgets (e.g. a category - // button's label). Doing our own teardown here keeps mComponents clean for the native code re-entering the tab - // rebuilds. + // Leaving the Mods tab for another category: tear our rows down FIRST, before the native category switch runs. They + // would then linger in mComponents on the other category - re-localized by a language change and walked by the native + // layout - which can corrupt unrelated widgets (e.g. a category button's label). Doing our own teardown here keeps + // mComponents clean for the native code re-entering the tab rebuilds. if (!is_mods_tab && !g_rows.empty()) { destroy_rows(screen); @@ -4795,12 +4614,9 @@ namespace big::mod_settings return result; } - // Value-change hook for our native number-box rows. GUIComponentNumBox::SetNumberValue is called (with notify=true) - // on every user step - left/right, arrow click, keyboard or controller. We run the original first (it clamps to - // [min,max], refreshes the value text, updates arrow visibility), then, if `this` is one of our rows, persist the - // post-clamp value to the config entry and run the restart-required tracking `notify` is false only for our own - // initial paint in make_numbox_row, so filtering on it keeps that from being recorded as a change. This fires for - // native settings num-boxes too, hence the `find_row` filter. + // Value-change hook for our native number-box rows. GUIComponentNumBox::SetNumberValue is called (with notify=true) on + // every user step - left/right, arrow click, keyboard or controller. This fires for native settings num-boxes too, + // hence the `find_row` filter. static void hook_GUIComponentNumBox_SetNumberValue(void* self, float value, bool notify) { big::g_hooking->get_original()(self, value, notify); @@ -4840,14 +4656,11 @@ namespace big::mod_settings } // Value-change hook for our native slider rows GUIComponentSlider::SetFraction is called with notify=true on every - // user drag/left-right adjust (the native handler also rewrites the value text to a percentage). We run the - // original, then, for our rows, map the post-clamp fraction to the [min,max] value, snap that to the setting's step - // for storage/display, and restore the real value text notify is false only for our own initial paint - // (make_slider_row), so filtering on it skips that. Fires for the native audio sliders too, hence the find_row - // filter. We deliberately leave mFraction continuous (we do NOT write the snapped value back to it): the native - // adjust accumulates a small per-frame delta into mFraction, so re-snapping it each frame would discard any delta - // smaller than half a step and a partial stick deflection would never move the slider. The fill therefore tracks - // the stick smoothly (as the vanilla sliders do) while the stored value and the value text snap to step. + // user drag/left-right adjust (the native handler also rewrites the value text to a percentage). Fires for the native + // audio sliders too, hence the find_row filter. We deliberately leave mFraction continuous (we do NOT write the + // snapped value back to it): the native adjust accumulates a small per-frame delta into mFraction, so re-snapping it + // each frame would discard any delta smaller than half a step and a partial stick deflection would never move the + // slider. static void hook_GUIComponentSlider_SetFraction(void* self, float fraction, bool notify) { big::g_hooking->get_original()(self, fraction, notify); @@ -4910,7 +4723,7 @@ namespace big::mod_settings return; } const double cur = row->entry ? row->entry->get_value_base() : - get_virtual_value(row->stem, g_view_section, row->setting_key).as_number; + get_virtual_value(row->stem, row_io_section(row), row->setting_key).as_number; const double idx = std::round((cur - min_v) / step_v); double v = min_v + (idx + dir) * step_v; if (v < min_v) @@ -4925,16 +4738,12 @@ namespace big::mod_settings } // Discrete keyboard/controller stepping for our slider rows, and a disabled-row guard. The native - // GUIComponentSlider::HandleInput slides mFraction continuously (axisSum * speed * dt behind a 0.5 dead-zone, - // summing dpad, arrow keys, WASD and the left stick), so a small tap can land back on the same snapped value. For - // our rows under keyboard/controller (UseMouse off) we bypass that path and move exactly one step on each - // left/right press edge, so every input changes the value by at least one step and a held direction cannot creep - // between steps, gated on the slider's own mFocused@0x548 (the native slide gate) so only the entered slider - not - // every visible one - reacts. Mouse drag (UseMouse on) and every native slider keep the original continuous - // behaviour. If the edge probes are missing the whole path is skipped at install time, so this only runs when both - // are available. A DISABLED slider row must not adjust in any mode: the native mouse-drag path (UseMouse && - // button-down && backing under cursor) never checks mIsUseable, so we swallow its input here or a greyed slider - // would still drag under the mouse. + // GUIComponentSlider::HandleInput slides mFraction continuously (axisSum * speed * dt behind a 0.5 dead-zone, summing + // dpad, arrow keys, WASD and the left stick), so a small tap can land back on the same snapped value. For our rows + // under keyboard/controller (UseMouse off) we bypass that path and move exactly one step on each left/right press + // edge, so every input changes the value by at least one step and a held direction cannot creep between steps, gated + // on the slider's own mFocused@0x548 (the native slide gate) so only the entered slider - not every visible one - + // reacts. static bool hook_GUIComponentSlider_HandleInput(void* self, void* input, float dt) { PanelRow* row = self ? find_row(reinterpret_cast(self)) : nullptr; @@ -4961,10 +4770,7 @@ namespace big::mod_settings } // Button-click hook GUIComponentButton overrides. GUIComponent::OnClicked (vtable slot +0x100, the engine's - // terminal-click), so this is where our button rows' clicks land. For our rows the engine returns false (they have - // no bound activate function) but still plays the press sound, so we must match the row regardless of the return - // value. The actual panel rebuild is deferred to the Update hook, where mutating the component vectors is safe - // (this runs mid input iteration). + // terminal-click), so this is where our button rows' clicks land. static bool hook_GUIComponentButton_OnClicked(GUIComponent* self, std::uint64_t location) { // Clicking the restart message box's button closes the game (forced restart). Re-validate the button's owner is @@ -5006,15 +4812,15 @@ namespace big::mod_settings { // Predict the flipped state for the press cue. get() drives the flip, but may be nil (value not set yet), // so fall back to the row's last-drawn state - matching the flip below. - const auto cur = get_virtual_value(matched_row.stem, g_view_section, matched_row.setting_key); + const auto cur = get_virtual_value(matched_row.stem, row_io_section(&matched_row), matched_row.setting_key); const bool cur_on = cur.type == virtual_value::kind::boolean ? cur.as_bool : matched_row.toggle_value; stage_toggle_press_sound(self, !cur_on); } - // A matched but disabled row (a greyed action button or a context-restricted setting that stays selectable so - // its note still shows on hover) must not react to a click. The base GUIComponent::OnClicked plays mPressSound - // and swaps the button's pressed graphic even though the row has no usable activate, so calling it would sound - // and visually "press" a control the user cannot use. Skip the base call and consume the click as a no-op. + // A matched but disabled row (a greyed action button or a context-restricted setting that stays selectable so its + // note still shows on hover) must not react to a click. The base GUIComponent::OnClicked plays mPressSound and swaps + // the button's pressed graphic even though the row has no usable activate, so calling it would sound and visually + // "press" a control the user cannot use. if (matched && matched_row.disabled) { return false; @@ -5042,9 +4848,8 @@ namespace big::mod_settings { auto* entry = matched_row.entry; - // Boolean settings toggle in place other types open a freetext editor. Number-boxNumber-box (stepper) - // rows are GUIComponentNumBox, not buttons, so their clicks never reach this hook - the num-box handles - // its own arrow clicks and left/right natively. + // Boolean settings toggle in place. Other types open a freetext editor. Number-box rows are + // GUIComponentNumBox, so their clicks never reach this hook. if (entry && entry->type() == typeid(bool)) { const bool new_value = !entry->get_value_base(); @@ -5072,13 +4877,11 @@ namespace big::mod_settings set_toggle_graphic(self, new_value); // If the author declared this setting restart-required, flag/clear the restart and record the - // change so the popup can list what. Forced it. + // change for the popup. note_change_if_restart_required(entry, new_value ? "on" : "off"); - // Toggling the mod's master "enabled" switch changes which other rows are greyed toggling any bool - // in a view that has dynamic (function) rows may change their disabled/hidden/range - so rebuild - // the settings view in place on the next Update to re-evaluate them live. The rebuild is instant - // (same view), preserving scroll. + // Toggling "enabled" changes greying. Toggling any bool in a dynamic view may change + // disabled/hidden/range. Rebuild in place next Update, preserving scroll. if (matched_row.is_enabled_toggle || g_view_has_dynamic) { g_pending_view = View::mod_settings; @@ -5094,13 +4897,9 @@ namespace big::mod_settings } else if (matched_row.is_virtual_input && matched_row.is_toggle) { - // Interactive virtual boolean: flip through the Lua set() callback and repaint the toggle. Like the - // virtual enum/slider, dependent-row refresh is handled by the settle timer that commit_row_bool - // arms (no instant rebuild - there is no master-enable greying to apply immediately). get() drives - // the flip, but may be nil (the value is not set yet, the case `type` covers); fall back to the - // row's last-drawn state so the first click still toggles. After this set() runs, get() returns the - // stored value, so later clicks read it directly. - const auto cur = get_virtual_value(matched_row.stem, g_view_section, matched_row.setting_key); + // Interactive virtual boolean: flip through Lua set() and repaint. After set(), get() returns the + // stored value for later clicks. + const auto cur = get_virtual_value(matched_row.stem, row_io_section(&matched_row), matched_row.setting_key); const bool cur_on = cur.type == virtual_value::kind::boolean ? cur.as_bool : matched_row.toggle_value; const bool new_value = !cur_on; commit_row_bool(&matched_row, new_value); @@ -5127,12 +4926,11 @@ namespace big::mod_settings } // Restores vertical breathing room around action-button rows (Apply/Reset), whose taller Button_Secondary box would - // otherwise crowd the neighbouring setting rows on the uniform grid. Run right after the native UpdateScrollState - // has laid every on-page row on the grid: within the current page we nudge each action button down by - // button_extra_lead and shift the rows below it by lead+trail (accumulated). The shift is applied through the - // engine's own SetLocation so each row's child components follow (a raw m_location_y write leaves them behind, which - // is what drove the earlier slider-bar drift). It is page-aware and reapplied every frame off the freshly-gridded - // positions, so it never accumulates. + // otherwise crowd the neighbouring setting rows on the uniform grid. Run right after the native UpdateScrollState has + // laid every on-page row on the grid: within the current page we nudge each action button down by button_extra_lead + // and shift the rows below it by lead+trail (accumulated). The shift is applied through the engine's own SetLocation + // so each row's child components follow (a raw m_location_y write leaves them behind, which is what drove the earlier + // slider-bar drift). static void apply_button_spacing(MiscSettingsScreen* screen) { const std::size_t first = screen->m_page_start_index; @@ -5164,13 +4962,8 @@ namespace big::mod_settings // Points the native scroll arrows at the keyboard/controller nav so it can page. The spatial search // (SearchInDirection) walks a ray from the selected row in the pressed direction and picks the nearest selectable - // component whose eval point (location + mFreeFormSelectOffset) is close to the ray. Off-page rows are unselectable - // (fade target 0), so from the last on-page row the ray finds nothing below and cannot advance. We make each arrow - // the target instead by placing its eval point exactly where the next (down) or previous (up) row would be: one - // row_pitch beyond the actual last/first visible row, at that row's location. Because the offset is set relative - // to the arrow's own location, any shared parent offset cancels, so the eval point tracks the real row position. - // The arrow's own auto-activate then fires ScrollDown/ScrollUp - // when the nav lands on it. Off the last/first page the arrow is hidden and unselectable, so this is inert there. + // component whose eval point (location + mFreeFormSelectOffset) is close to the ray. Off the last/first page the arrow + // is hidden and unselectable, so this is inert there. static void enable_arrow_keyboard_paging(MiscSettingsScreen* screen) { if (g_rows.empty()) @@ -5197,15 +4990,14 @@ namespace big::mod_settings *reinterpret_cast(bytes + component_auto_activate_offset) = true; }; - // Down arrow aims one row below the last visible row; up arrow one row above the first visible row. + // Down arrow aims one row below the last visible row. Up arrow one row above the first visible row. aim(screen->m_down_arrow, g_rows[last].component, row_pitch); aim(screen->m_up_arrow, g_rows[first].component, -row_pitch); } - // Detour on the native scroll pass. The original lays every on-page row on the uniform grid (writing each row's - // mLocation). We hook it to give the action-button rows their vertical breathing room (apply_button_spacing) and to - // re-aim the scroll arrows' keyboard-nav eval points at the new page edges after each layout (see - // enable_arrow_keyboard_paging), inside MiscSettingsScreen::Update before the row hit-test. + // Detour on the native scroll pass. We hook it to give the action-button rows their vertical breathing room + // (apply_button_spacing) and to re-aim the scroll arrows' keyboard-nav eval points at the new page edges after each + // layout (see enable_arrow_keyboard_paging), inside MiscSettingsScreen::Update before the row hit-test. static void hook_MiscSettingsScreen_UpdateScrollState(void* self) { big::g_hooking->get_original()(self); @@ -5241,14 +5033,14 @@ namespace big::mod_settings } } - // A slider drag, number-box adjust, or freetext commit in a view that has dynamic (function) rows re-evaluates - // those rows (e.g. an apply button enabling itself when a value changes). The rebuild frees and recreates the - // rows, so it is deferred two ways: a short debounce absorbs the per-frame slider hook, and while the user is - // still actively adjusting a row (with keyboard/controller, or an in-progress mouse drag) the rebuild is HELD - // until they finish - otherwise it would free the focused slider mid-adjust or interrupt a mouse drag. The - // debounce also coalesces a burst of edits into a single rebuild: each commit re-arms the timer, only the final - // expiry rebuilds, build_panel clears the timer so its own rebuild cancels any still-pending one, and the - // !g_nav_pending guard folds this into a rebuild already queued by an instant path (a toggle / action). + // A slider drag, number-box adjust, or freetext commit in a view that has dynamic (function) rows re-evaluates those + // rows (e.g. an apply button enabling itself when a value changes). The rebuild frees and recreates the rows, so it + // is deferred two ways: a short debounce absorbs the per-frame slider hook, and while the user is still actively + // adjusting a row (with keyboard/controller, or an in-progress mouse drag) the rebuild is HELD until they finish - + // otherwise it would free the focused slider mid-adjust or interrupt a mouse drag. The debounce also coalesces a + // burst of edits into a single rebuild: each commit re-arms the timer, only the final expiry rebuilds, build_panel + // clears the timer so its own rebuild cancels any still-pending one, and the !g_nav_pending guard folds this into a + // rebuild already queued by an instant path (a toggle / action). if (g_dynamic_refresh_settle > 0.0f) { if (!on_mods_tab) @@ -5272,11 +5064,8 @@ namespace big::mod_settings g_pending_section = g_view_section; g_nav_pending = true; - // Pin the row the user just edited so the rebuild keeps focus on it. Keyboard/controller focus - // is restored by build_panel's cursor tracking; in mouse mode g_keep_active_row drives the - // few-frame re-assert that steadies the prompt, description and highlight over the rebuild - // (a set() that changes a config value can otherwise blink them onto a neighbour). Mirrors the - // click/toggle path, which pins its row the same way. + // Pin the edited row so the rebuild keeps focus on it. Keyboard/controller focus is restored by + // build_panel's cursor tracking. if (GUIComponent* active = active_row_component(screen)) { if (const PanelRow* fr = find_row(active)) @@ -5294,10 +5083,9 @@ namespace big::mod_settings // Only act while this screen is actually showing the Mods tab. if (on_mods_tab) { - // Stepping from a mod's settings back to the mod overview is the "done configuring this mod" point: if - // a restart-required setting changed this session, show the restart prompt now (it forces the restart) - // and stay on the current view under it, rather than returning to the overview. Only the final step out - // of the mod (to the list) triggers it stepping between nested groups stays within mod_settings. + // Stepping from a mod's settings back to the mod overview is the "done configuring this mod" point: if a + // restart-required setting changed this session, show the restart prompt now (it forces the restart) and stay on + // the current view under it, rather than returning to the overview. const bool leaving_mod = (g_view == View::mod_settings) && (g_pending_view == View::mod_list); bool prompted = false; if (leaving_mod && g_restart_required && !g_restart_prompt_shown) @@ -5315,9 +5103,9 @@ namespace big::mod_settings } // For a few frames after a click-triggered rebuild, pin the clicked row as hovered/selected and re-apply our - // prompt/description over the native hover pass, which settles over the new layout a frame later and would - // otherwise blink the prompt, description or highlight onto a neighbouring row. Runs before the original Update - // (which reads mMouseOverComponent for the description) so this frame is already correct. + // prompt/description over the native hover pass, which settles over the new layout a frame later and would otherwise + // blink the prompt, description or highlight onto a neighbouring row. Runs before the original Update (which reads + // mMouseOverComponent for the description) so this frame is already correct. if (g_keep_active_frames > 0 && on_mods_tab) { reassert_keep_active_row(screen); @@ -5357,15 +5145,8 @@ namespace big::mod_settings // While a freetext setting is being edited, read Enter (confirm) and Escape (cancel) from the game's own per-frame // input, commit/cancel here, then swallow the screen's input handling entirely so menu navigation and the // Escape-to-close do not react. Committing here (rather than in Update) is important: HandleInput returns true this - // frame, so a submitting mouse click is swallowed and cannot also activate the row it lands on. Returning true - // without calling the original bypasses the whole close chain (the base. MenuScreen::HandleInput is only reached - // via this function's tail-call). Not editing, controller/keyboard, nothing entered yet: we drive two per-option - // behaviours the native focus delegates would (which our injected rows lack). Select (A/Enter) enters a slider or - // enum row so the stick then adjusts it. The native code exits it on the next. A/B And inside a mod's settings,. - // Back/CancelBack/Cancel (controller B/keyboard Esc) steps back one level - in option-navigation mode the native - // Cancel handler returns the cursor to the tab bar instead of reaching our. ExitScreen back-nav, so we detect it - // here (before the original) and run the back-nav ourselves. Both swallow the press. When a widget is already - // entered we do nothing: native routes the stick to it and exits on. A/B. + // frame, so a submitting mouse click is swallowed and cannot also activate the row it lands on. ExitScreen back-nav, + // so we detect it here (before the original) and run the back-nav ourselves. static bool hook_MiscSettingsScreen_HandleInput(void* self, void* input, float x) { if (g_editing) @@ -5403,9 +5184,8 @@ namespace big::mod_settings } } - // Back/Cancel inside a mod's settings steps back one level (nested group -> parent section, root -> mod - // list) instead of the native return-to-tab-bar In the mod list. It is left to the native handler. The - // restart prompt is shown when stepping from a mod's settings back to the overview (see apply_nav). + // Back/Cancel inside a mod's settings steps back one level instead of returning to the tab bar. In the mod + // list it is left to the native handler. The restart prompt is shown by apply_nav. if (g_view == View::mod_settings && !g_nav_pending && control_pressed(input, g_controls_cancel)) { request_back_nav(); @@ -5413,10 +5193,9 @@ namespace big::mod_settings } } - // The native handler runs the keyboard/controller nav, including the on-screen scroll arrow's auto-activate at - // a page edge, which pages via ScrollDown/ScrollUp and selects the new page's edge row. Capture the page - // index across the call so we can correct that landing when it falls on a disabled row (see - // redirect_page_landing). Only meaningful under keyboard/controller on the Mods tab. + // The native handler runs the keyboard/controller nav, including the on-screen scroll arrow's auto-activate at a page + // edge, which pages via ScrollDown/ScrollUp and selects the new page's edge row. Capture the page index across the + // call so we can correct that landing when it falls on a disabled row (see redirect_page_landing). const bool track_paging = on_mods_tab && !(g_use_mouse && *g_use_mouse); const std::uint32_t page_before = screen->m_page_start_index; @@ -5432,11 +5211,9 @@ namespace big::mod_settings // Close funnel for the options screen: every way the user dismisses it (Escape key, controller B, or clicking the // on-screen "Exit" button) converges here (MiscSettingsScreen::ExitScreen, vtable slot 7), before any fade/teardown - // and while mScreenManager is valid. If a restart is required, show the native message box and DO NOT run the - // original (veto the close): The box is modal over the still-open options screen and its button closes the game. A - // restart-required change must not be cancellable (that would require undoing the change), so the restart is - // forced. If the native dialog cannot be built, the change is already saved to the mod's config (it applies on the - // next manual restart), so we just let the screen close normally. + // and while mScreenManager is valid. If a restart is required, show the native message box and DO NOT run the original + // (veto the close): The box is modal over the still-open options screen and its button closes the game. A + // restart-required change must not be cancellable (that would require undoing the change), so the restart is forced. static void hook_MiscSettingsScreen_ExitScreen(void* self) { // Inside a mod's settings, Esc/controller B/the on-screen Back button steps up one level: a nested group @@ -5460,10 +5237,10 @@ namespace big::mod_settings } } - // The screen is really closing now. Tear our rows down first: the engine frees a MenuScreen's components - // through its reflection helper (which our rows are deliberately not registered in), not by walking - // mComponents, so on close it would neither free nor double-free them - they would just leak destroy_rows is a - // no-op when g_rows is already empty (e.g. closing off the Mods tab). + // The screen is really closing now. Tear our rows down first: the engine frees a MenuScreen's components through its + // reflection helper (which our rows are deliberately not registered in), not by walking mComponents, so on close it + // would neither free nor double-free them - they would just leak destroy_rows is a no-op when g_rows is already empty + // (e.g. closing off the Mods tab). g_options_screen_open = false; // stop gating on_change on this now-closing screen. g_dynamic_refresh_settle = 0.0f; // drop any pending numeric-change refresh for the closing screen. destroy_rows(screen); @@ -5474,10 +5251,7 @@ namespace big::mod_settings // Reset choke-point: sgg::MiscSettingsScreen::RestoreDefaults (virtual slot 21) is the single handler for both the // [I]/MenuInfo control and a mouse click on the on-screen Reset button. On our Mods tab the native reset is a no-op - // (our rows' mDataValue is not a ConfigOptionsField key). Inside a single mod's settings we run our own reset of - // that mod's config and still call the original for the native confirm animation + sound and glyph refresh (on our - // tab it touches no real game settings) In the mod list/overview we swallow it entirely: Reset is intentionally - // unavailable there (its prompt is hidden too) so users can't reset every mod's config by mistake. + // (our rows' mDataValue is not a ConfigOptionsField key). static void hook_MiscSettingsScreen_RestoreDefaults(void* self) { auto* screen = static_cast(self); @@ -5495,12 +5269,10 @@ namespace big::mod_settings void register_hooks() { - // Resolve every engine symbol, RVA and offset the Mods tab depends on up front. The symbol map is built from - // the game's live PDB, so if the game updates and a required function moved or was renamed it resolves to null - // here likewise the hardcoded RVAs and struct offsets this feature was reverse-engineered against only match - // one specific Ship build. If anything required is missing we log exactly what and install NO hooks, so the tab - // is cleanly skipped instead of crashing the game. The rom.mod_settings Lua config API is wired separately - // (bind_config_api) and keeps working regardless, so mods can still author and read their config. + // Resolve every engine symbol, RVA and offset the Mods tab depends on up front. The symbol map is built from the + // game's live PDB, so if the game updates and a required function moved or was renamed it resolves to null here + // likewise the hardcoded RVAs and struct offsets this feature was reverse-engineered against only match one specific + // Ship build. std::vector missing; const auto require = [&](const char* name) -> gmAddress { @@ -5548,10 +5320,9 @@ namespace big::mod_settings g_button_dtor = big::hades2_symbol_to_address["sgg::GUIComponentButton::~GUIComponentButton"].as_func(); g_disable = big::hades2_symbol_to_address["sgg::GUIComponentButton::Disable"].as_func(); - // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the number-box - // stepper). The engine has no slider factory, so a slider is hand-built from the base GUIComponent/image/ - // text-box constructors and Defaults - all resolved by name here. SetFraction is both the initial set and the - // drag hook (installed below). The slider vtable is resolved by name (RVA fallback) once the build is verified. + // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the number-box stepper). + // SetFraction is both the initial set and the drag hook (installed below). The slider vtable is resolved by name (RVA + // fallback) once the build is verified. g_gui_component_ctor = big::hades2_symbol_to_address["sgg::GUIComponent::GUIComponent"].as_func(); g_image_ctor = big::hades2_symbol_to_address["sgg::GUIComponentImage::GUIComponentImage"].as_func(); g_textbox_ctor = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GUIComponentTextBox"].as_func(); @@ -5559,9 +5330,8 @@ namespace big::mod_settings const auto slider_set_fraction = big::hades2_symbol_to_address["sgg::GUIComponentSlider::SetFraction"]; g_slider_set_fraction = slider_set_fraction.as_func(); - // Controller focus: ComponentFocused makes a row the focused option (so the stick reaches it),. GetState reads - // the Back/Cancel control edge for our drilldown back-nav. Both by name (Controls::Cancel is resolved by name - // above). Optional - their absence only degrades controller support, not the tab. + // Controller focus: ComponentFocused makes a row the focused option, and GetState reads Back/Cancel for + // drilldown back-nav. Both are optional by-name lookups. g_component_focused = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ComponentFocused"].as_func(); g_set_mouse_over = big::hades2_symbol_to_address["sgg::MenuScreen::SetMouseOver"].as_func(); g_input_get_state = big::hades2_symbol_to_address["sgg::InputHandler::GetState"].as_func(); @@ -5573,35 +5343,24 @@ namespace big::mod_settings g_input_was_left_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasLeftPressed"].as_func(); g_input_was_right_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasRightPressed"].as_func(); - // Native-settings flush before a forced restart. SaveProfile. SaveProfile serializes the active profile - // (language, volumes, graphics, gameplay/interface toggles) to disk. ACTIVE_PROFILE is the profile-name string - // it takes. Both are named PDB globals/functions. Optional - if either is missing we simply skip the flush (the - // forced restart still happens), so native changes made this session would be lost, but nothing crashes. + // Native-settings flush before a forced restart. SaveProfile persists language, volumes, graphics and gameplay + // toggles. Optional - missing symbols only mean those native edits may wait for a normal save. g_save_profile = big::hades2_symbol_to_address["sgg::ProfileManager::SaveProfile"].as_func(); g_active_profile = big::hades2_symbol_to_address["sgg::ProfileManager::ACTIVE_PROFILE"].as(); // Config/control GLOBALS, resolved by name (update-proof - they are named PDB data symbols that move with - // .data/.rdata across game updates, so an anchor-relative RVA cannot be trusted for them). Optional: a null - // UseMouse just makes us assume controller/keyboard mode (mouse checks are `g_use_mouse && *g_use_mouse`), a - // null language skips the locale font fallback, and null Cancel/Select only degrade controller back/select - // detection - none crash. UseMouse is the global bool that is false in controller/keyboard mode; Language is - // the eastl string with the current display-language code; Cancel/Select are the remappable Back (controller - // B/Esc) and Select (controller A/Enter) controls whose first int indexes InputHandler's state array. + // .data/.rdata across updates, so an anchor-relative RVA cannot be trusted). None crash. g_use_mouse = big::hades2_symbol_to_address["sgg::ConfigOptions::UseMouse"].as(); g_config_language = big::hades2_symbol_to_address["sgg::ConfigOptions::Language"].as(); g_controls_cancel = big::hades2_symbol_to_address["sgg::Controls::Cancel"].as(); g_controls_select = big::hades2_symbol_to_address["sgg::Controls::Select"].as(); - // The num-box factory (a template instantiation) and the restart-dialog ctor /. AddScreen overloads cannot be - // picked by name from the PDB, so they are addressed by hardcoded RVA off the button-ctor anchor. Those RVAs - - // and every struct offset this feature uses - are valid only for the Ship build they were captured against. - // Symbols resolved by name above auto-adapt across game updates, but these hardcoded values do NOT, so a game - // update can move them and hang/crash the options screen. Gate the whole menu on the exact game build via its - // PDB GUID (a unique per-build id captured while the symbol map is built): after an update the GUID no longer - // matches, and the menu is cleanly skipped (the rom.mod_settings Lua config API is unaffected) until - // Hell2Modding is updated. Only the latest build is supported: to move to a new one, re-validate the - // RVAs/offsets against it (compare the new Ship Hades2.pdb), then replace this GUID (logged at startup and in - // the warning below) with the new build's. Current build: Hades II Ship v1.139251. + // The num-box factory (a template instantiation) and the restart-dialog ctor and AddScreen overloads cannot be picked + // by name from the PDB, so they are addressed by hardcoded RVA off the button-ctor anchor. Those RVAs and every + // struct offset this feature uses are valid only for the Ship build they were captured against, and unlike the + // name-resolved symbols above they do NOT auto-adapt, so a game update can move them and hang/crash the options + // screen. Gate the whole menu on the exact build via its PDB GUID: after an update the GUID no longer matches and the + // menu is cleanly skipped (the rom.mod_settings Lua API is unaffected) until Hell2Modding is updated. static constexpr const char* validated_pdb_guid = "48ca71f9-5fbb-4209-a14a9738171ce4eb"; const bool build_validated = big::hades2_pdb_guid == validated_pdb_guid; @@ -5649,9 +5408,7 @@ namespace big::mod_settings } // Build verified and every required symbol resolved: derive the remaining anchor-relative helpers and hook. - // These are all .text functions (the templated num-box factory and the overloaded MessageDialog ctor/ - // AddScreen that cannot be picked unambiguously by name, plus TeleportCursorTo). The config/control globals - // are resolved by name above (they move with .data/.rdata). + // These are .text functions that cannot be picked unambiguously by name, plus TeleportCursorTo. const auto anchor_base = anchor.as() - anchor_rva; g_message_dialog_ctor = reinterpret_cast(anchor_base + message_dialog_ctor_rva); g_add_screen = reinterpret_cast(anchor_base + add_screen_rva); @@ -5669,17 +5426,13 @@ namespace big::mod_settings g_slider_vtable = anchor_base + slider_vtable_rva; } - // Build a patched copy of the slider vtable whose GetArea (+0x98) and GetScreenArea (+0xA0) slots return a - // one-row hit rect. The native slider GetArea unions the slider's sub-components into a screen-spanning - // rectangle that, through the nearest-anchor hover tiebreak, hijacks mouse hover (and keyboard nav) from other - // rows on a mixed page. Copying the whole table keeps every other virtual (ctor/dtor/Draw/HandleInput/...) - // intact; only the two area getters are redirected. + // Build a patched copy of the slider vtable whose GetArea/GetScreenArea slots return a one-row hit rect (see + // build_row_area_vtable). The native slider GetArea unions the slider's sub-components into a screen-spanning + // rectangle that, through the nearest-anchor hover tiebreak, hijacks mouse hover (and keyboard nav) from other rows + // on a mixed page. if (g_slider_vtable) { - std::memcpy(g_slider_vtable_copy, reinterpret_cast(g_slider_vtable), sizeof(g_slider_vtable_copy)); - g_slider_vtable_copy[0x98 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); - g_slider_vtable_copy[0xA0 / sizeof(std::uintptr_t)] = reinterpret_cast(&row_bounded_area); - g_slider_vtable_patched = reinterpret_cast(g_slider_vtable_copy); + g_slider_vtable_patched = build_row_area_vtable(g_slider_vtable_copy, sizeof(g_slider_vtable_copy), g_slider_vtable); } g_feature_enabled = true; @@ -5707,7 +5460,7 @@ namespace big::mod_settings { static auto set_fraction_hook = hooking::detour_hook_helper::add_queue("sgg::GUIComponentSlider::SetFraction", slider_set_fraction); - // Discrete keyboard/controller stepping needs the left/right edge probes; without them our slider rows keep + // Discrete keyboard/controller stepping needs the left/right edge probes. Without them our slider rows keep // the native continuous slide, so only install the input override when both resolved. const auto slider_handle_input = big::hades2_symbol_to_address["sgg::GUIComponentSlider::HandleInput"]; if (slider_handle_input && g_input_was_left_pressed && g_input_was_right_pressed) diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 5f0c3da..0df9d00 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -49,8 +49,8 @@ namespace big::mod_settings // An author-declared menu group (configDesc `groups`): a category in the in-game menu that does NOT correspond to a // config section. It lets a mod present a flat (or differently nested) config under an arbitrary menu tree, by // moving entries into these groups with a per-entry `group`. `id` is the identity used in a `group` path (the table - // key in configDesc.groups); name/description are shown in the menu (resolved to the current language); order sorts - // it among its siblings (else first-declared order); children are nested sub-groups. + // key in configDesc.groups). name/description are shown in the menu (resolved to the current language). order sorts + // it among its siblings (else first-declared order). children are nested sub-groups. struct menu_group { std::string id; @@ -143,7 +143,7 @@ namespace big::mod_settings std::optional get_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); // True if (section, key) carries a configDesc entry (a description string or a table). The menu shows only - // described keys; an undescribed config key is hidden, so a mod's internal/bookkeeping config values do not clutter + // described keys. An undescribed config key is hidden, so a mod's internal/bookkeeping config values do not clutter // the settings page. The mod's master "enabled" toggle is always shown regardless (handled in build_mod_settings). bool setting_is_described(const std::string& guid, const std::string& section, const std::string& key); @@ -188,7 +188,7 @@ namespace big::mod_settings // A configDesc entry with NO backing config value that explicitly marks itself `virtual = true`. It renders as a // menu row whose value comes from Lua callbacks instead of a .cfg config entry: a read-only row uses `text`, and an - // interactive row uses `get` (read) + `set` (write). Collected at load; the callables stay in the Lua descs + // interactive row uses `get` (read) + `set` (write). Collected at load. The callables stay in the Lua descs // registry and are resolved at render. The rest of its metadata (displayName/description/order/min/max/values/...) // is read the same way as a config setting's, via resolve_setting_metadata against (section, key). struct virtual_row_info From 110492e57c7f3e76387ba1eaae9a976028ab3b3f Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:03:08 +0100 Subject: [PATCH 066/100] Updated docs --- docs/mod_settings/README.md | 3 +-- docs/mod_settings/config_schema.lua | 11 +++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index 181b33a..93add1c 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -45,7 +45,7 @@ below. Two other kinds of `configDesc` entry have their own fields and sections: | `disabledDescription` | string \| localization table \| callback | Description shown in place of `description` while the setting is greyed by its own `disabled` field, to explain why. Falls back to `description` when omitted. Not used for context-restricted or mod-disabled rows. | | `freetext` | boolean | Force a bounded number to be a free-text entry instead of a slider. | | `restartRequired` | boolean | Force the user to restart the game when this setting is changed. | -| `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict when this setting can be changed: `"any"` (default), `"mainMenu"` (only from the main menu), `"inSave"` (only while a save is loaded - both in the Crossroads and mid-run), or `"inHub"` (only while in the Crossroads). When the current context does not match, the row is shown read-only with a note. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | +| `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict where the row can be edited: `"any"` (default), `"mainMenu"` ( only from the main menu), `"inSave"` (only while a save is loaded), or `"inHub"` (only in the Crossroads). Outside of the allowed context the row shows as disabled. In most cases, `any` will work, only restrict when actively changing a live value during gameplay, or save-specific data. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | | `showAsPercentage` | boolean | Append "%" to the value. | | `isPercentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | | `onChange` | `fun(key, new_value)` | Called after the setting is changed in the in-game menu. Use it to apply the change to the loaded run. See below. | @@ -170,7 +170,6 @@ local configDesc = { hermes_shrine_chance = { displayName = "Hermes Shrine Chance", min = 0, max = 100, - editableContext = "inSave", onChange = function(key, new_value) mod.ApplyHermesShrineChance(new_value) -- re-apply the value to the live run end, diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 0170dd0..8650191 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -72,8 +72,9 @@ --- Mark that changing this setting requires a game restart. The menu forces the player --- to restart when they leave the mod menu after changing it. ---@field restartRequired? boolean ---- If this setting can be changed only in the main menu, only in a save (run or Crossroads), only in the Crossroads, or anywhere. ---- When the current context does not match, the row is shown read-only with a note. +--- Restrict where this row can be edited: only the main menu, only in a save, only in the Crossroads +--- or anywhere (default). Outside of the allowed context the row shows as disabled. +--- In most cases, "any" will work, only restrict when actively changing a live value during gameplay, or save-specific data. --- The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. ---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" --- Append "%" to the displayed value. @@ -98,7 +99,8 @@ ---@field description? mod_settings.dynamic_string --- Sort key among the section's rows, lower first. ---@field order? mod_settings.dynamic_number ---- When the button is activated: only in the main menu, only in a save (run or Crossroads), only in the Crossroads, or anywhere. +--- Restrict where the button is enabled: main menu, in a save, in the Crossroads, or anywhere (default "any"). +--- In most cases, "any" will work, only restrict when actively changing a live value during gameplay, or save-specific data. ---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" --- Grey the button out (non-interactive) while this is true. Updates live while the menu is open (e.g. --- grey an "Apply" button until a value has actually changed). @@ -162,7 +164,8 @@ --- explain why it is unavailable. Ignored for a context-restricted row (only editable in main menu etc.) or --- while the whole mod is disabled. Defaults to the normal `description` when omitted. ---@field disabledDescription? mod_settings.dynamic_string ---- When the button is activated: only in the main menu, only in a save (run or Crossroads), only in the Crossroads, or anywhere. +--- Restrict where this row can be edited: main menu, in a save, in the Crossroads, or anywhere (default "any"). +--- In most cases, "any" will work, only restrict when actively changing a live value during gameplay, or save-specific data. ---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" --- Row label. Defaults to a prettified version of the config key (e.g. `myCool_Setting` -> "My Cool Setting"). ---@field displayName? mod_settings.dynamic_string From 915987106979c0816208f297849440a4acaaf7c4 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:03:57 +0100 Subject: [PATCH 067/100] Rename onChange to onChanged, updated default ordering --- docs/mod_settings/README.md | 27 +++++------ docs/mod_settings/config_schema.lua | 10 ++-- src/hades2/mod_settings/config_api.cpp | 59 +++++++++++++++--------- src/hades2/mod_settings/mod_settings.cpp | 20 ++++---- src/hades2/mod_settings/mod_settings.hpp | 5 +- 5 files changed, 67 insertions(+), 54 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index 93add1c..c306edc 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -39,7 +39,7 @@ below. Two other kinds of `configDesc` entry have their own fields and sections: | `step` | number \| callback | Slider/number step size (default 1). Will clamp user input automatically. | | `values` | array \| callback | Enum: the values stored in the `.cfg` file. If present, the input will turn into a cycler (such as for the selected display). | | `labels` | array of (string \| localization table) \| callback | Display labels parallel to `values`, only used in the in-game mod menu. | -| `order` | number \| callback | Sort key for custom ordering config entries in the menu, lower first. | +| `order` | number \| callback | Sort key for custom ordering config entries in the menu, lower first. When omitted, rows follow their definition order in `configDesc`. | | `hidden` | boolean | Hide the setting from the menu entirely. Static only - use `disabled` for a condition that changes while the menu is open. | | `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. See below. | | `disabledDescription` | string \| localization table \| callback | Description shown in place of `description` while the setting is greyed by its own `disabled` field, to explain why. Falls back to `description` when omitted. Not used for context-restricted or mod-disabled rows. | @@ -48,7 +48,7 @@ below. Two other kinds of `configDesc` entry have their own fields and sections: | `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict where the row can be edited: `"any"` (default), `"mainMenu"` ( only from the main menu), `"inSave"` (only while a save is loaded), or `"inHub"` (only in the Crossroads). Outside of the allowed context the row shows as disabled. In most cases, `any` will work, only restrict when actively changing a live value during gameplay, or save-specific data. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | | `showAsPercentage` | boolean | Append "%" to the value. | | `isPercentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | -| `onChange` | `fun(key, new_value)` | Called after the setting is changed in the in-game menu. Use it to apply the change to the loaded run. See below. | +| `onChanged` | `fun(key, new_value)` | Called after the setting is changed through the menu, in any context. See below. | ## Config keys named like reserved fields @@ -160,31 +160,32 @@ local configDesc = { } ``` -## Reacting to changes (`onChange`) +## Reacting to changes (`onChanged`) -Give a setting an `onChange` function to e.g. apply its new value to the live game when the player -changes it in the in-game options menu. It receives the setting's key and the new value: +Give a setting an `onChanged` function to react when the player changes it through the options menu. Use it to +apply the new value to the live game, and/or to update **other rows'** dynamic `min`/`max`/`values`/`disabled`. +It receives the setting's key and the new value: ```lua local configDesc = { hermes_shrine_chance = { displayName = "Hermes Shrine Chance", min = 0, max = 100, - onChange = function(key, new_value) - mod.ApplyHermesShrineChance(new_value) -- re-apply the value to the live run + onChanged = function(key, new_value) + if game.CurrentRun then mod.ApplyHermesShrineChance(new_value) end end, }, } ``` The callback fires AFTER the new value is stored and the `.cfg` is saved, so reading the setting back -(directly or via your `config` proxy) returns the new value. It runs only for an edit made through the -in-game options menu, so: +(directly or via your `config` proxy) returns the new value. Note: -- It is **never called in the main menu** - there is no loaded run to apply to, and Lua game-data edits - are discarded when a save loads. -- It is **not called for other config writes** (e.g. from imgui or the config file). -- Re-writing the same value is a no-op and does not fire, so an `onChange` that writes another setting +- It **fires in any context** (main menu or in a save), so guard anything that needs a live run - `CurrentRun` + and `GameState` are absent in the main menu. +- It is **not called for other config writes** (e.g. from imgui or the config file) - only for edits made through + this menu. +- Re-writing the same value is a no-op and does not fire, so an `onChanged` that writes another setting cannot loop. - Errors thrown in the callback are logged and do not propagate into the game. diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 8650191..ffec5d3 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -55,7 +55,7 @@ --- localization table. When omitted, the raw values are shown in the cycler. ---@field labels? mod_settings.localized_string[] | fun(): mod_settings.localized_string[] --- Sort key for custom ordering config entries in the menu, lower first. ---- When omitted, rows keep the order they are defined in the default config you provide. +--- When omitted, rows keep the order they are defined in in configDesc. ---@field order? mod_settings.dynamic_number --- Hide this setting from the menu entirely. Static only (evaluated when the menu builds) - for a --- condition that changes while the menu is open, use `disabled`, which greys the setting out. @@ -81,10 +81,10 @@ ---@field showAsPercentage? boolean --- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). ---@field isPercentage? boolean ---- Called after this setting's value is changed through the in-game options menu, with the setting's key ---- and the new value. Use it to apply the change to the loaded run. It is not called in the main menu. +--- Called after this setting's value is changed through the options menu, with the setting's key and the new value. +--- Fires in any context - guard live-run access (game.CurrentRun and GameState are absent in the main menu). --- Re-writing the same value is a no-op and does not fire. Errors are logged, not propagated. ----@field onChange? fun(key: string, new_value: boolean|number|string) +---@field onChanged? fun(key: string, new_value: boolean|number|string) --- Move this row to a different or new menu category, overriding its config-section placement (see mod_settings.group). ---@field group? mod_settings.group @@ -173,7 +173,7 @@ --- to about 35 characters so it leaves enough space for free-text input strings. ---@field description? mod_settings.dynamic_string --- Sort key for custom ordering config entries in the menu, lower first. ---- When omitted, rows keep the order they are defined in the default config you provide. +--- When omitted, rows keep the order they are defined in in configDesc. ---@field order? number --- Move this row to a different or new menu category, overriding its config-section placement (see mod_settings.group). ---@field group? mod_settings.group diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index e1d07e5..c2e97b6 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -170,16 +170,16 @@ namespace big::mod_settings return it != g_menu_groups.end() ? it->second : std::vector{}; } - // Byte offset of a key's definition (" =") in config.lua source (whole-word, not "=="), or npos. The first - // match is the key's place in the `config` defaults table (before configDesc), the author's intended display order. - static std::size_t find_key_definition(const std::string& src, const std::string& key) + // Byte offset of a key's definition (" =") in config.lua source at or after `start` (whole-word, not "=="), + // or npos. Occurrences inside strings/prose do not match because they are not followed by a bare '='. + static std::size_t find_key_definition(const std::string& src, const std::string& key, std::size_t start = 0) { auto is_ident = [](char c) { return std::isalnum(static_cast(c)) != 0 || c == '_'; }; - for (std::size_t pos = src.find(key); pos != std::string::npos; pos = src.find(key, pos + 1)) + for (std::size_t pos = src.find(key, start); pos != std::string::npos; pos = src.find(key, pos + 1)) { if (pos > 0 && is_ident(src[pos - 1])) { @@ -202,6 +202,22 @@ namespace big::mod_settings return std::string::npos; } + // Rank position for a described entry: where it appears in configDesc (the menu-layout table), so the menu's + // fallback order follows how the author laid out configDesc rather than the `config` defaults table. A config-backed + // key appears first in `config` (the defaults, defined before configDesc) and again in its configDesc entry, so we + // take the SECOND occurrence. A virtual row or action has no config default, so its only occurrence is already in + // configDesc. Returns npos (sorts last) when the key cannot be located, e.g. a numeric/bracketed key. + static std::size_t desc_definition_offset(const std::string& src, const std::string& key, bool config_backed) + { + const std::size_t first = find_key_definition(src, key); + if (!config_backed || first == std::string::npos) + { + return first; + } + const std::size_t second = find_key_definition(src, key, first + 1); + return second != std::string::npos ? second : first; + } + static std::string serialize_option(const sol::object& v); // defined below. // Parses a user-facing string field: either a plain scalar (stored under the empty key) or a localization table @@ -643,7 +659,7 @@ namespace big::mod_settings } // Shallow-copies a description table with every dynamic (function) field replaced by its evaluated value, so - // extract_metadata can read it as static. Event callables are left as-is: `onChange`, `action`, and a virtual row's + // extract_metadata can read it as static. Event callables are left as-is: `onChanged`, `action`, and a virtual row's // `get`/`set`/`text`. static sol::table resolve_description(sol::state_view state, const sol::table& desc, const std::string& guid) { @@ -656,7 +672,7 @@ namespace big::mod_settings continue; } const std::string field = k.as(); - if (field == "onChange" || field == "action" || field == "get" || field == "set" || field == "text") + if (field == "onChanged" || field == "action" || field == "get" || field == "set" || field == "text") { out[k] = v; continue; @@ -753,7 +769,7 @@ namespace big::mod_settings "editableContext", "showAsPercentage", "isPercentage", - "onChange", + "onChanged", "action", "virtual", "get", @@ -951,10 +967,10 @@ namespace big::mod_settings } // Routes toml_v2's config_entry::m_setting_changed (fired after a value changes and the file is saved) to a Lua - // onChange callback, passing the new value and the key. Fires only for edits made through the in-game options menu - // (gated by on_change_callbacks_enabled), never in the main menu or from a mod's own writes. A same-value write is a - // no-op, so a callback that writes back cannot loop. Stored on the entry (owned by the mod's config_file, destroyed - // with the Lua state on App::Reset), so the captured sol reference never dangles. Called protected. + // onChanged callback, passing the new value and the key. Fires for any edit made through our options menu (main menu + // or in a save, gated by on_change_callbacks_enabled), but not from a mod's own config write outside the menu. A + // same-value write is a no-op, so a callback that writes back cannot loop. Stored on the entry (owned by the mod's + // config_file, destroyed with the Lua state on App::Reset), so the captured sol reference never dangles. Called protected. static void attach_on_change(toml_v2::config_file::config_entry_base* entry, sol::protected_function callback) { if (!entry || !callback.valid()) @@ -972,7 +988,7 @@ namespace big::mod_settings if (!result.valid()) { const sol::error err = result; - LOG(WARNING) << "[mod_settings] onChange callback failed for " << changed->m_definition.m_section << "." + LOG(WARNING) << "[mod_settings] onChanged callback failed for " << changed->m_definition.m_section << "." << changed->m_definition.m_key << ": " << err.what(); } }; @@ -1324,14 +1340,14 @@ namespace big::mod_settings { meta_out.push_back({section, key, extract_metadata(desc.as())}); - // A leaf may also declare an onChange callback. Attach it to the bound entry so a menu edit (or the + // A leaf may also declare an onChanged callback. Attach it to the bound entry so a menu edit (or the // mod's own write) of this setting notifies the mod in Lua. if (bound_entry) { - sol::object on_change = desc.as()["onChange"]; - if (on_change.is()) + sol::object on_changed = desc.as()["onChanged"]; + if (on_changed.is()) { - attach_on_change(bound_entry, on_change.as()); + attach_on_change(bound_entry, on_changed.as()); } } } @@ -1452,22 +1468,23 @@ namespace big::mod_settings source_text = ss.str(); } } + // Rank every described entry by where it appears in configDesc (the menu-layout table), so an author who sets no + // explicit `order` gets the rows in the order they laid out in configDesc. Config-backed keys are located via + // their configDesc entry (their second source occurrence), virtual rows and actions via their only one. std::vector> by_offset; // (offset, section, key). for (const auto& [def, entry] : cf->m_entries) { - const std::size_t off = source_text.empty() ? std::string::npos : find_key_definition(source_text, def.m_key); + const std::size_t off = source_text.empty() ? std::string::npos : desc_definition_offset(source_text, def.m_key, true); by_offset.emplace_back(off, def.m_section, def.m_key); } - // Rank virtual rows in the same source-order space as the config entries so they interleave with config rows. for (const auto& vr : virtual_rows) { - const std::size_t off = source_text.empty() ? std::string::npos : find_key_definition(source_text, vr.key); + const std::size_t off = source_text.empty() ? std::string::npos : desc_definition_offset(source_text, vr.key, false); by_offset.emplace_back(off, vr.section, vr.key); } - // Same for actions, so an un-ordered action button interleaves with the rows around its configDesc definition. for (const auto& a : actions) { - const std::size_t off = source_text.empty() ? std::string::npos : find_key_definition(source_text, a.key); + const std::size_t off = source_text.empty() ? std::string::npos : desc_definition_offset(source_text, a.key, false); by_offset.emplace_back(off, a.section, a.key); } std::stable_sort(by_offset.begin(), diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index e027783..7cfbe7a 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2386,13 +2386,13 @@ namespace big::mod_settings // True while a native options screen is open. Set in the ctor, cleared when it closes in ExitScreen. static bool g_options_screen_open = false; - // True while a setting change should notify its mod through an on_change callback: an options screen is currently open - // AND it was opened in-game (a save is loaded). This gates on_change so a callback fires only for an edit made through - // the in-game options menu that can be applied to the live run - never from the main menu, and never from a mod's own - // config write outside the menu. + // True while a setting change should notify its mod through an on_change callback: an options screen is currently + // open. Fires for any edit made through our options menu, in the main menu or in a save (so a callback can also + // drive other rows' dynamic min/max/values/disabled), but not from a mod's own config write outside the menu. + // Callbacks must guard live-run access (game.CurrentRun and GameState may be absent in the main menu). bool on_change_callbacks_enabled() { - return g_options_screen_open && g_opened_in_game; + return g_options_screen_open; } // The MiscSettingsScreen ctor's "opened from" argument is the opening screen (sgg::MenuScreen*): a MainMenuScreen when @@ -2800,8 +2800,8 @@ namespace big::mod_settings } // Row order: the master "enabled" toggle is pinned to the top then rows with an author `order` (ascending) then - // The rest. Ties and absent order fall back to config.lua source order (a group's rank is its earliest-defined - // descendant's). + // the rest by configDesc source order (a group's rank is its earliest-defined descendant's, so a drill-in sits + // where its content is declared rather than being pinned above the settings). std::stable_sort(items.begin(), items.end(), [](const panel_item& a, const panel_item& b) @@ -2822,11 +2822,7 @@ namespace big::mod_settings { return a.order < b.order; } - if (!a.has_order && a.is_group != b.is_group) - { - return a.is_group; // with no explicit order, groups are pinned above settings - } - return a.appearance < b.appearance; // equal/absent order -> config.lua source order + return a.appearance < b.appearance; // equal/absent order -> configDesc source order }); for (const auto& it : items) diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 0df9d00..7013f45 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -264,8 +264,7 @@ namespace big::mod_settings localized_text mod_opt_out_description(const std::string& guid); // True while a setting change should notify its mod through an on_change callback: a native options screen is - // currently open and it was opened in-game (a save is loaded). Consulted by the config API so an on_change fires - // only for an edit made through the in-game options menu, which can be applied to the live run - never in the main - // menu, and never from a mod's own config write outside the menu. + // currently open. Consulted by the config API so an on_change fires for any edit made through the options menu (main + // menu or in a save), but not from a mod's own config write outside the menu. bool on_change_callbacks_enabled(); } // namespace big::mod_settings From acc09cbf4d34d8886469fe7b1a3ab135cbcbfde6 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:00:26 +0100 Subject: [PATCH 068/100] Only reset settings within current tree view --- src/hades2/mod_settings/config_api.cpp | 81 +++++++-------- src/hades2/mod_settings/mod_settings.cpp | 119 ++++++++++++++++------- src/hades2/mod_settings/mod_settings.hpp | 10 +- 3 files changed, 129 insertions(+), 81 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index c2e97b6..89f4d61 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -1848,58 +1848,59 @@ namespace big::mod_settings return virtual_value::kind::string; } - bool reset_virtual_rows_to_defaults(const std::string& guid) + bool reset_virtual_row_to_default(const std::string& guid, const std::string& section, const std::string& key) { - std::vector rows; + bool interactive = false; { std::scoped_lock lock(g_metadata_mutex); const auto it = g_virtual_rows.find(guid); - if (it == g_virtual_rows.end()) + if (it != g_virtual_rows.end()) { - return false; + for (const auto& vr : it->second) + { + if (vr.section == section && vr.key == key) + { + interactive = vr.interactive; // read-only rows have no set() to restore through. + break; + } + } } - rows = it->second; // copy so the lock is not held across the Lua get/set callbacks below. + } + if (!interactive) + { + return false; } - bool any_changed = false; - for (const auto& vr : rows) + const auto meta = resolve_setting_metadata(guid, section, key); + if (!meta || !meta->has_default) { - if (!vr.interactive) - { - continue; // read-only rows have no set() to restore through. - } - const auto meta = resolve_setting_metadata(guid, vr.section, vr.key); - if (!meta || !meta->has_default) - { - continue; // only rows that declare a `default` are reset. - } + return false; // only rows that declare a `default` are reset. + } - // Prefer the live get() kind, then an explicit `type`, then `values` (enum -> string), then a guess from - // the default's serialized form. - const virtual_value cur = get_virtual_value(guid, vr.section, vr.key); - virtual_value::kind kind = cur.type; - if (kind == virtual_value::kind::none) - { - kind = kind_of_widget(meta->type); - } - if (kind == virtual_value::kind::none) - { - kind = !meta->values.empty() ? virtual_value::kind::string : guess_kind_from_serialized(meta->default_value); - } + // Prefer the live get() kind, then an explicit `type`, then `values` (enum -> string), then a guess from the + // default's serialized form. + const virtual_value cur = get_virtual_value(guid, section, key); + virtual_value::kind kind = cur.type; + if (kind == virtual_value::kind::none) + { + kind = kind_of_widget(meta->type); + } + if (kind == virtual_value::kind::none) + { + kind = !meta->values.empty() ? virtual_value::kind::string : guess_kind_from_serialized(meta->default_value); + } - const virtual_value target = virtual_value_from_serialized(kind, meta->default_value); - const bool unchanged = cur.type == target.type - && ((kind == virtual_value::kind::boolean && cur.as_bool == target.as_bool) - || (kind == virtual_value::kind::number && cur.as_number == target.as_number) - || (kind == virtual_value::kind::string && cur.as_string == target.as_string)); - if (unchanged) - { - continue; - } - set_virtual_value(guid, vr.section, vr.key, target); - any_changed = true; + const virtual_value target = virtual_value_from_serialized(kind, meta->default_value); + const bool unchanged = cur.type == target.type + && ((kind == virtual_value::kind::boolean && cur.as_bool == target.as_bool) + || (kind == virtual_value::kind::number && cur.as_number == target.as_number) + || (kind == virtual_value::kind::string && cur.as_string == target.as_string)); + if (unchanged) + { + return false; } - return any_changed; + set_virtual_value(guid, section, key, target); + return true; } // Lua API: Function. Table: mod_settings. Name: opt_out. Param: description: string: Optional. A plain string or a diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 7cfbe7a..6bf70b8 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2539,6 +2539,48 @@ namespace big::mod_settings return found; } + // Resolves an entry's menu path: its `group` override (validated against author groups and the mod's config + // sections) else its config section. A `group` naming neither is logged once (per stem+path) and falls back to the + // config-section placement, so a typo leaves the row where its value lives rather than stranding it in a bogus + // group. Shared by the panel builder and Reset so both agree on where an entry lives. `view_cfg` is the mod's + // config file (may be null, in which case only author groups are accepted). + static std::string resolve_entry_menu_path(const std::string& stem, const std::vector& author_groups, toml_v2::config_file* view_cfg, const std::string& csection, const std::vector& group) + { + if (group.empty()) + { + return csection; + } + const std::string m = menu_path_of(csection, group); + if (find_author_group(author_groups, m)) + { + return m; // a declared author group (the common case) + } + if (view_cfg) // or an existing config section the row is being merged into + { + const std::string desc_prefix = m + "."; + for (const auto& [k, e] : view_cfg->m_entries) + { + if (k.m_section == m || k.m_section.rfind(desc_prefix, 0) == 0) + { + return m; + } + } + } + const std::string warn_key = stem + '\0' + m; + if (g_warned_group_overrides.insert(warn_key).second) + { + LOG(WARNING) << "[mod_settings] " << stem << ": `group` target '" << m << "' is neither a config section nor a category declared in configDesc `groups`; the row falls back to its config-section placement. Declare it in `groups` if it is a new menu category."; + } + return csection; + } + + // True if menu path `p` lies within the current view scope `scope`: the scope page itself or any of its descendant + // subgroups. Used to limit Reset to the drilled-in group (and its subgroups) rather than the whole mod. + static bool menu_path_in_scope(const std::string& p, const std::string& scope) + { + return p == scope || p.rfind(scope + ".", 0) == 0; + } + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render as // setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem, const std::string& section) @@ -2575,37 +2617,11 @@ namespace big::mod_settings // not exist as config sections. Looked up when a child group is created to pick its display name/order/source. const std::vector author_groups = mod_menu_groups(stem); - // Resolves an entry's menu path: its `group` override (validated) else its config section. A `group` that names - // neither a declared author group nor a config section is logged once and falls back to the config-section - // placement, so a typo leaves the row visible where its value lives rather than stranding it in a bogus group. + // Resolves an entry's menu path: its `group` override (validated) else its config section. Delegates to the + // shared resolver so the panel and Reset agree on placement. auto resolve_menu_path = [&](const std::string& csection, const std::vector& group) -> std::string { - if (group.empty()) - { - return csection; - } - const std::string m = menu_path_of(csection, group); - if (find_author_group(author_groups, m)) - { - return m; // a declared author group (the common case) - } - if (view_cfg) // or an existing config section the row is being merged into - { - const std::string desc_prefix = m + "."; - for (const auto& [k, e] : view_cfg->m_entries) - { - if (k.m_section == m || k.m_section.rfind(desc_prefix, 0) == 0) - { - return m; - } - } - } - const std::string warn_key = stem + '\0' + m; - if (g_warned_group_overrides.insert(warn_key).second) - { - LOG(WARNING) << "[mod_settings] " << stem << ": `group` target '" << m << "' is neither a config section nor a category declared in configDesc `groups`; the row falls back to its config-section placement. Declare it in `groups` if it is a new menu category."; - } - return csection; + return resolve_entry_menu_path(stem, author_groups, view_cfg, csection, group); }; // Where an entry (living in config section `csection`, with an optional `group` override) sits relative to the @@ -4225,18 +4241,27 @@ namespace big::mod_settings return text.substr(pos + marker.size()); } - // Restores the current mod's config entries (g_view_stem) to their defaults, saving each change and flagging any - // restart-required ones. The default comes from the config.lua value captured by rom.mod_settings.load when available, - // and otherwise from the config entry's own stored default (so. + // Restores the current mod's config entries to their defaults, but only those whose MENU path lies within the + // current view (the drilled-in group and its subgroups), so a Reset inside a group leaves sibling and parent groups + // untouched. At the mod root (g_view_section == root_section) every described entry is in scope, so the whole mod + // resets. The menu path follows the configDesc grouping (a `group` override else the config section), matching what + // the page shows. The default comes from the config.lua value captured by rom.mod_settings.load when available, and + // otherwise from the config entry's own stored default. static bool reset_settings_to_defaults() { bool any_changed = false; + const std::vector author_groups = mod_menu_groups(g_view_stem); + toml_v2::config_file* mod_cfg = nullptr; // any config file of this mod, for virtual-row path resolution. for (auto* cfg : toml_v2::config_file::g_config_files) { if (!cfg || cfg->m_config_file_stem_as_str.empty() || cfg->m_config_file_stem_as_str != g_view_stem) { continue; } + if (!mod_cfg) + { + mod_cfg = cfg; + } const std::string& guid = cfg->m_config_file_stem_as_str; for (auto& [def, entry] : cfg->m_entries) { @@ -4255,6 +4280,16 @@ namespace big::mod_settings continue; } + // Skip entries outside the current menu group. The `group` override is a static field, so the cheap + // stored metadata gives the placement. + const auto static_meta = get_setting_metadata(guid, def.m_section, def.m_key); + const std::vector grp = static_meta ? static_meta->group : std::vector{}; + const std::string mpath = resolve_entry_menu_path(guid, author_groups, cfg, def.m_section, grp); + if (!menu_path_in_scope(mpath, g_view_section)) + { + continue; + } + auto def_val = get_setting_default(guid, def.m_section, def.m_key); if (!def_val) { @@ -4273,11 +4308,23 @@ namespace big::mod_settings } } - // Interactive virtual rows are not config entries, so restore any that declare a `default` through their set() - // callback here (read-only rows and rows without a default are left untouched). - if (reset_virtual_rows_to_defaults(g_view_stem)) + // Interactive virtual rows are not config entries, so restore any in scope that declare a `default` through + // their set() callback here (read-only rows and rows without a default are left untouched). + for (const auto& vr : get_virtual_rows(g_view_stem, "")) { - any_changed = true; + if (!vr.interactive) + { + continue; + } + const std::string mpath = resolve_entry_menu_path(g_view_stem, author_groups, mod_cfg, vr.section, vr.group); + if (!menu_path_in_scope(mpath, g_view_section)) + { + continue; + } + if (reset_virtual_row_to_default(g_view_stem, vr.section, vr.key)) + { + any_changed = true; + } } return any_changed; } diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 7013f45..fc495da 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -236,11 +236,11 @@ namespace big::mod_settings // row has no `set`. Call on the game thread while the Lua state is alive. void set_virtual_value(const std::string& guid, const std::string& section, const std::string& key, const virtual_value& value); - // Restores every interactive virtual row of mod `guid` that declares a `default` to that default, via its set() - // callback. Read-only rows and rows without a `default` are left untouched. Returns true if any row's value - // actually changed. Used by the menu Reset (config-backed settings recover their own defaults separately). Call on - // the game thread while the Lua state is alive. - bool reset_virtual_rows_to_defaults(const std::string& guid); + // Restores one interactive virtual row of mod `guid` (identified by its config `section` and `key`) to its declared + // `default`, via its set() callback. No-op returning false if the row is not interactive, declares no `default`, or + // already holds it. The menu Reset scopes which rows to restore by their menu path and calls this per row (config- + // backed settings recover their own defaults separately). Call on the game thread while the Lua state is alive. + bool reset_virtual_row_to_default(const std::string& guid, const std::string& section, const std::string& key); // Rank of a setting's definition in its config.lua source (0 = first). Used to order rows that have no // author-declared `order` in config-file order. Returns INT_MAX for keys not bound via rom.mod_settings.load (e.g. From c13f7801605b877d100d68c13443581628356dcd Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:52:30 +0100 Subject: [PATCH 069/100] Quiet redundant ERROR and traceback for failed mod callbacks --- src/hades2/mod_settings/config_api.cpp | 36 ++++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 89f4d61..5480eb3 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -639,6 +639,26 @@ namespace big::mod_settings return node[key]; } + // A minimal Lua message handler that returns the error object unchanged. Unlike ReturnOfModding's global default + // handler it neither appends a stack traceback nor logs the failure at ERROR (and does not count it against the + // mod's error tally), leaving the raw one-line error for our own concise WARNING to report. + static int silent_error_handler(lua_State* /*L*/) + { + return 1; // keep the single error value already on the stack. + } + + // Invokes a mod-supplied Lua callback protected, but with the silent handler above instead of ReturnOfModding's + // default. Our callers report failures themselves with one concise WARNING and recover, so a callback that + // legitimately fails in some contexts (e.g. reading run state from the main menu) does not also spam the console + // with an alarming ERROR plus full traceback. `fn` is taken by value so the caller's stored callback is untouched. + template + static sol::protected_function_result call_mod_callback(sol::protected_function fn, Args&&... args) + { + const lua_CFunction handler = &silent_error_handler; + fn.set_error_handler(sol::object(fn.lua_state(), sol::in_place, handler)); + return fn(std::forward(args)...); + } + // Calls a dynamic description field (a Lua function) protected, returning its result, or nil on error (logged). // Non-function values are returned unchanged. static sol::object evaluate_field(const sol::object& value, const std::string& guid, const char* field) @@ -648,7 +668,7 @@ namespace big::mod_settings return value; } sol::protected_function fn = value; - sol::protected_function_result rv = fn(); + sol::protected_function_result rv = call_mod_callback(fn); if (!rv.valid()) { const sol::error err = rv; @@ -984,7 +1004,7 @@ namespace big::mod_settings return; } const sol::object value = entry_get(callback.lua_state(), changed); - sol::protected_function_result result = callback(changed->m_definition.m_key, value); + sol::protected_function_result result = call_mod_callback(callback, changed->m_definition.m_key, value); if (!result.valid()) { const sol::error err = result; @@ -1637,7 +1657,7 @@ namespace big::mod_settings return; } sol::protected_function fn = act; - sol::protected_function_result rv = fn(); + sol::protected_function_result rv = call_mod_callback(fn); if (!rv.valid()) { const sol::error err = rv; @@ -1690,7 +1710,7 @@ namespace big::mod_settings if (text.get_type() == sol::type::function) { sol::protected_function fn = text; - sol::protected_function_result rv = fn(); + sol::protected_function_result rv = call_mod_callback(fn); if (!rv.valid()) { const sol::error err = rv; @@ -1722,7 +1742,7 @@ namespace big::mod_settings return out; } sol::protected_function fn = get; - sol::protected_function_result rv = fn(); + sol::protected_function_result rv = call_mod_callback(fn); if (!rv.valid()) { const sol::error err = rv; @@ -1771,9 +1791,9 @@ namespace big::mod_settings sol::protected_function_result rv; switch (value.type) { - case virtual_value::kind::boolean: rv = fn(value.as_bool); break; - case virtual_value::kind::number: rv = fn(value.as_number); break; - case virtual_value::kind::string: rv = fn(value.as_string); break; + case virtual_value::kind::boolean: rv = call_mod_callback(fn, value.as_bool); break; + case virtual_value::kind::number: rv = call_mod_callback(fn, value.as_number); break; + case virtual_value::kind::string: rv = call_mod_callback(fn, value.as_string); break; default: return; // nothing to write. } if (!rv.valid()) From bda7a3da744b3adf7ac230d551102c003ee1ac8e Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:08:25 +0100 Subject: [PATCH 070/100] Added regions for better discoverability --- src/hades2/mod_settings/config_api.cpp | 47 +++++++++++++++++++++ src/hades2/mod_settings/mod_settings.cpp | 52 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 5480eb3..9891a3c 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -28,6 +28,7 @@ using namespace al; namespace big::mod_settings { + #pragma region Metadata registries and accessors // Author-declared per-setting metadata (display name, bounds, enum options, ordering, restart flag), populated from // each mod's config.lua by rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Only settings with a @@ -170,6 +171,10 @@ namespace big::mod_settings return it != g_menu_groups.end() ? it->second : std::vector{}; } + #pragma endregion + + #pragma region Source-order ranking helpers + // Byte offset of a key's definition (" =") in config.lua source at or after `start` (whole-word, not "=="), // or npos. Occurrences inside strings/prose do not match because they are not followed by a bare '='. static std::size_t find_key_definition(const std::string& src, const std::string& key, std::size_t start = 0) @@ -218,6 +223,10 @@ namespace big::mod_settings return second != std::string::npos ? second : first; } + #pragma endregion + + #pragma region Config.lua parsing helpers + static std::string serialize_option(const sol::object& v); // defined below. // Parses a user-facing string field: either a plain scalar (stored under the empty key) or a localization table @@ -431,6 +440,10 @@ namespace big::mod_settings } } + #pragma endregion + + #pragma region Metadata extraction + // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. Captures the // author-only inputs that can't be inferred (name, bounds, enum options/labels, order, hidden, restart). The widget // kind is not stored - the menu derives it from the value's type plus the presence of `values` (enum). @@ -578,6 +591,10 @@ namespace big::mod_settings return m; } + #pragma endregion + + #pragma region Description navigation and dynamic-field resolution + // The Lua-side registry (rom.mod_settings._descs) mapping guid -> the mod's raw configDesc table, kept alive so // dynamic description fields and action callbacks can be evaluated at render. Recreated each Lua state, so it never // dangles. Returns a nil object if the guid has no stored description. @@ -702,6 +719,10 @@ namespace big::mod_settings return out; } + #pragma endregion + + #pragma region Action and virtual-row collection + // Reads the static (non-function) action metadata common to collection and dynamic re-resolution. static void read_action_fields(const sol::table& entry, action_info& a) { @@ -931,6 +952,10 @@ namespace big::mod_settings } } + #pragma endregion + + #pragma region Config entry access and change hooks + // Finds the config entry for (section, key), or nullptr. m_entries is keyed by config_definition, so this is a // direct map lookup. static toml_v2::config_file::config_entry_base* find_entry(toml_v2::config_file* cf, const std::string& section, const std::string& key) @@ -1035,6 +1060,10 @@ namespace big::mod_settings return value > 0; } + #pragma endregion + + #pragma region Config proxy + // Registry keys for the config proxy: one shared metatable, plus two weak-keyed maps from each wrapper table to the // config_file and section it points at, so the metamethods can recover them per call. static constexpr const char* k_proxy_metatable = "h2m_mod_config_metatable"; @@ -1279,6 +1308,10 @@ namespace big::mod_settings return recover(ts, self).inext(ts, index); } + #pragma endregion + + #pragma region Default binding and config.lua load + // A setting's extracted metadata together with the section/key it belongs to, collected while walking config.lua // and then folded into the registry. struct collected_metadata @@ -1564,6 +1597,10 @@ namespace big::mod_settings return make_proxy(ts, cf.get(), "config"); } + #pragma endregion + + #pragma region Dynamic metadata and game-state accessors + std::optional resolve_setting_metadata(const std::string& guid, const std::string& section, const std::string& key) { if (!big::g_lua_manager) @@ -1803,6 +1840,10 @@ namespace big::mod_settings } } + #pragma endregion + + #pragma region Virtual-row value helpers and reset + // Parses a serialized scalar (as produced by serialize_option) back to a double, or 0.0 if it is not numeric. static double parse_serialized_number(const std::string& s) { @@ -1923,6 +1964,10 @@ namespace big::mod_settings return true; } + #pragma endregion + + #pragma region Opt-out and API registration + // Lua API: Function. Table: mod_settings. Name: opt_out. Param: description: string: Optional. A plain string or a // localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note. Excludes the calling // mod from the in-game menu: it stays listed but greyed out and cannot be opened. Works with Chalk or @@ -2000,4 +2045,6 @@ namespace big::mod_settings // C++ statics (which would dangle across a Lua-state reset). ns["_descs"] = state.create_table(); } + #pragma endregion + } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 6bf70b8..f4c87fb 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -28,6 +28,8 @@ using namespace al; namespace big::mod_settings { + #pragma region Native screen offsets, RVAs, and constants + using sgg::GUIComponent; using sgg::MenuScreen; using sgg::MiscSettingsScreen; @@ -276,6 +278,10 @@ namespace big::mod_settings using mouse_button_down_fn = bool (*)(void* input_handler); using input_dir_pressed_fn = bool (*)(void* input_handler); + #pragma endregion + + #pragma region Native bindings, panel model, and menu state + // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid { @@ -571,6 +577,10 @@ namespace big::mod_settings // Journey" "zerp-DreamDiveTweaks" -> "Dream Dive Tweaks". static std::string key_to_display(const std::string& key); // shared friendly-name logic, defined below + #pragma endregion + + #pragma region Mod identity, text, and Mods-tab helpers + static std::string display_name_from_stem(const std::string& stem) { const auto dash = stem.find('-'); @@ -824,6 +834,10 @@ namespace big::mod_settings } } + #pragma endregion + + #pragma region Native row construction and styling + // Writes an in-place EASTL short-string (SSO, up to 22 chars) into a component field. static void set_sso_string(void* field, const char* text) { @@ -1615,6 +1629,10 @@ namespace big::mod_settings return reinterpret_cast(s); } + #pragma endregion + + #pragma region Row teardown and mod list + // Removes the first pointer equal to `value` from an eastl vector by shifting the tail down in place - the same // unlink the engine's DoShowCategory performs. No-op if not present. The backing storage is left owned by the // vector. @@ -1748,6 +1766,10 @@ namespace big::mod_settings } } + #pragma endregion + + #pragma region Value formatting, freetext editing, and commit + // Turns an identifier into a friendly display string: underscores become spaces, and camelCase/PascalCase word // boundaries are split ("z_ThisConfigKey" -> "z. The first letter is capitalized ("enabled" -> "Enabled"). static std::string key_to_display(const std::string& key) @@ -2358,6 +2380,10 @@ namespace big::mod_settings } } + #pragma endregion + + #pragma region Editability context and menu-path helpers + // True if `key` is the mod's master enable switch ("enabled", any case). static bool is_enabled_key(const std::string& key) { @@ -2581,6 +2607,10 @@ namespace big::mod_settings return p == scope || p.rfind(scope + ".", 0) == 0; } + #pragma endregion + + #pragma region Panel builder + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render as // setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem, const std::string& section) @@ -3418,6 +3448,10 @@ namespace big::mod_settings } } + #pragma endregion + + #pragma region Panel sync, focus, and navigation + // Matches the native category-switch transition: the incoming page fades in and there is no fade-out crossover. Native // UpdateScrollState sets each on-page row's mFadeTarget to 1 and each off-page row's to 0, and GUIComponent::Update // (driven by MenuScreen::Update, which the original runs before this) eases mFadeOpacity toward the target at dt * @@ -4220,6 +4254,10 @@ namespace big::mod_settings build_panel(screen, instant); } + #pragma endregion + + #pragma region Reset to defaults + // The serialized default of a config entry, read from the entry itself via the public write_description (whose last // output line is "#. The serialized form uses the same converter as get_serialized_value, so it round-trips through // set_serialized_value. @@ -4344,6 +4382,10 @@ namespace big::mod_settings } } + #pragma endregion + + #pragma region Native dialogs and dependency checks + // True when the game's current display language uses a CJK font (zh-CN, zh-TW, ja, ko). Those fonts have no glyph for // the non-breaking space U+00A0 and draw a visible '*' instead, so the restart message uses regular spaces and a // U+3000 blank for them. @@ -4577,6 +4619,10 @@ namespace big::mod_settings return build_list_message("These enabled mods depend on this one:", dependents, "Disable them first to disable this mod."); } + #pragma endregion + + #pragma region Engine hooks + static void* hook_MiscSettingsScreen_ctor(void* self, void* screen_manager, void* opened_from, void* profile_name) { // Reset state BEFORE running the original ctor: the original ctor immediately shows the last-viewed category, @@ -5310,6 +5356,10 @@ namespace big::mod_settings big::g_hooking->get_original()(self); } + #pragma endregion + + #pragma region Hook registration + void register_hooks() { // Resolve every engine symbol, RVA and offset the Mods tab depends on up front. The symbol map is built from the @@ -5544,4 +5594,6 @@ namespace big::mod_settings "not reset mod settings"; } } + #pragma endregion + } // namespace big::mod_settings From 2b1fdaa7a1d38d2b776484be526141bac27c8357 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:49:33 +0100 Subject: [PATCH 071/100] Update GUID --- src/hades2/mod_settings/mod_settings.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index f4c87fb..b10a941 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -5454,7 +5454,7 @@ namespace big::mod_settings // name-resolved symbols above they do NOT auto-adapt, so a game update can move them and hang/crash the options // screen. Gate the whole menu on the exact build via its PDB GUID: after an update the GUID no longer matches and the // menu is cleanly skipped (the rom.mod_settings Lua API is unaffected) until Hell2Modding is updated. - static constexpr const char* validated_pdb_guid = "48ca71f9-5fbb-4209-a14a9738171ce4eb"; + static constexpr const char* validated_pdb_guid = "744ea71c-2c21-4b40-a6c486d1fa6647da"; const bool build_validated = big::hades2_pdb_guid == validated_pdb_guid; // Secondary sanity check on top of the GUID allow-list: the anchor (button ctor) must sit at its known module From 8277b78096fc51ff21f3290453cd4e43e8e7d3d4 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:50:28 +0100 Subject: [PATCH 072/100] Derive mod-settings row flags from the widget actually built --- src/hades2/mod_settings/mod_settings.cpp | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index b10a941..2df0b34 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -3283,6 +3283,9 @@ namespace big::mod_settings GUIComponent* row = nullptr; GUIComponent* value = nullptr; bool built_slider = false; + bool built_stepper = false; // num-box fallback used when the slider could not be built + bool built_enum = false; + bool built_toggle = false; // A setting that is unavailable in the current context (editable_context mismatch) or that the author marked // `disabled` is shown read-only: its current value in a greyed key+value row that still takes focus, so the @@ -3379,11 +3382,13 @@ namespace big::mod_settings if (entry->type() == typeid(bool)) { - row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), disabled); + row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), disabled); + built_toggle = row != nullptr; } else if (is_enum) { row = make_numbox_row(screen, label.c_str(), 0.0, static_cast(enum_values.size() - 1), 1.0, static_cast(enum_index), disabled, &enum_labels); + built_enum = row != nullptr; } else if (is_stepper) { @@ -3396,7 +3401,8 @@ namespace big::mod_settings } else { - row = make_numbox_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), disabled); + row = make_numbox_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), disabled); + built_stepper = row != nullptr; } } else @@ -3422,23 +3428,23 @@ namespace big::mod_settings const std::string mdesc = meta ? resolve_localized(meta->description) : std::string{}; pr.description = !mdesc.empty() ? mdesc : entry->m_description.m_description; - if (is_enum) + if (built_enum) { pr.is_enum = true; pr.enum_values = std::move(enum_values); pr.enum_labels = std::move(enum_labels); } - else if (is_stepper) + else if (built_slider || built_stepper) { pr.is_slider = built_slider; - pr.is_stepper = !built_slider; + pr.is_stepper = built_stepper; pr.stepper_min = meta->min; pr.stepper_max = meta->max; pr.stepper_step = step; pr.show_as_percentage = meta->show_as_percentage; pr.is_percentage = meta->is_percentage; } - else if (entry->type() == typeid(bool)) + else if (built_toggle) { pr.is_toggle = true; } From c93acaac46e6e70409bd36ac077483f33de244ea Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:23:37 +0100 Subject: [PATCH 073/100] Allocate and free native GUI objects through the game's CRT --- src/hades2/mod_settings/mod_settings.cpp | 67 ++++++++++++++++++------ 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 2df0b34..0cacc1f 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -852,6 +852,30 @@ namespace big::mod_settings bytes[0x17] = static_cast(0x17 - n); // SSO: remaining = capacity(23) - length } + // GUI objects are allocated and freed through the GAME's CRT, never H2M's: H2M is /MT while the game is /MD against + // ucrtbase, and the engine frees anything it owns (removed screens, a slider's sub-components, tf_new_internal + // blocks) with ucrtbase's _aligned_free. Crossing the boundary either way hands a heap a block it never owned. + // The deleting destructor is always called with flags = 0 for the same reason: flags = 1 routes to operator delete + // -> free() on an _aligned_malloc block, which the engine never does either. + using aligned_malloc_fn = void*(__cdecl*)(std::size_t, std::size_t); + using aligned_free_fn = void(__cdecl*)(void*); + + static aligned_malloc_fn g_game_aligned_malloc = nullptr; + static aligned_free_fn g_game_aligned_free = nullptr; + + static void* game_alloc(std::size_t size) + { + return g_game_aligned_malloc ? g_game_aligned_malloc(size, 8) : nullptr; // alignment 8 matches the engine + } + + static void game_free(void* block) + { + if (block && g_game_aligned_free) + { + g_game_aligned_free(block); + } + } + static GUIComponent* create_button(MiscSettingsScreen* screen) { if (!g_button_ctor || !g_push_back || !g_apply_data) @@ -859,7 +883,7 @@ namespace big::mod_settings return nullptr; } - auto* row = static_cast(_aligned_malloc(sgg::gui_component_button_size, 8)); + auto* row = static_cast(game_alloc(sgg::gui_component_button_size)); if (!row) { return nullptr; @@ -1531,7 +1555,7 @@ namespace big::mod_settings return nullptr; } - char* s = static_cast(_aligned_malloc(slider_sizeof, 8)); + char* s = static_cast(game_alloc(slider_sizeof)); if (!s) { return nullptr; @@ -1555,17 +1579,17 @@ namespace big::mod_settings // Four owned sub-components, each allocated then constructed at the origin (as the game does): two images (bar // background + fill) and two text boxes (left label + right value). - char* backing = static_cast(_aligned_malloc(image_sizeof, 8)); - char* fill = static_cast(_aligned_malloc(image_sizeof, 8)); - char* lbl = static_cast(_aligned_malloc(textbox_sizeof, 8)); - char* val = static_cast(_aligned_malloc(textbox_sizeof, 8)); + char* backing = static_cast(game_alloc(image_sizeof)); + char* fill = static_cast(game_alloc(image_sizeof)); + char* lbl = static_cast(game_alloc(textbox_sizeof)); + char* val = static_cast(game_alloc(textbox_sizeof)); if (!backing || !fill || !lbl || !val) { - _aligned_free(backing); - _aligned_free(fill); - _aligned_free(lbl); - _aligned_free(val); - _aligned_free(s); + game_free(backing); + game_free(fill); + game_free(lbl); + game_free(val); + game_free(s); return nullptr; } g_image_ctor(backing, 0); @@ -1694,7 +1718,7 @@ namespace big::mod_settings { // The num-box and slider are not GUIComponentButtons destruct through the component's own vtable so its owned // sub-components (num-box: box/label/value/arrows slider: background/fill/label/value) are freed too flags=0 - // destructs without the final operator delete, so we still _aligned_free the block ourselves. + // destructs without the final operator delete, so we still free the block ourselves (see game_free). void** vtbl = *reinterpret_cast(comp); auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset / sizeof(void*)]); dtor(comp, 0); @@ -1703,7 +1727,7 @@ namespace big::mod_settings { g_button_dtor(comp); } - _aligned_free(comp); + game_free(comp); }; for (const auto& row : g_rows) @@ -4485,9 +4509,9 @@ namespace big::mod_settings { if (screen_manager && g_message_dialog_ctor && g_add_screen) { - // The game's ScreenManager owns and frees this screen (with _aligned_free) once. It is dismissed H2M's static /MT - // UCRT and the game's ucrtbase share the process heap, so this. - void* dialog = _aligned_malloc(message_dialog_size, 8); + // The ScreenManager takes ownership and frees this with ucrtbase's _aligned_free, so it must come from the + // game's heap (see game_alloc). + void* dialog = game_alloc(message_dialog_size); if (dialog) { std::memset(dialog, 0, message_dialog_size); @@ -5454,6 +5478,17 @@ namespace big::mod_settings g_controls_cancel = big::hades2_symbol_to_address["sgg::Controls::Cancel"].as(); g_controls_select = big::hades2_symbol_to_address["sgg::Controls::Select"].as(); + // The game's CRT heap (see game_alloc). Missing means disable, never fall back to H2M's own CRT. + if (HMODULE ucrt = ::GetModuleHandleW(L"ucrtbase.dll")) + { + g_game_aligned_malloc = reinterpret_cast(::GetProcAddress(ucrt, "_aligned_malloc")); + g_game_aligned_free = reinterpret_cast(::GetProcAddress(ucrt, "_aligned_free")); + } + if (!g_game_aligned_malloc || !g_game_aligned_free) + { + missing.push_back("ucrtbase.dll _aligned_malloc/_aligned_free (the game's CRT heap)"); + } + // The num-box factory (a template instantiation) and the restart-dialog ctor and AddScreen overloads cannot be picked // by name from the PDB, so they are addressed by hardcoded RVA off the button-ctor anchor. Those RVAs and every // struct offset this feature uses are valid only for the Ship build they were captured against, and unlike the From 16e15e3cad7caf4710f894d1a890194fd55a9559 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:06:41 +0100 Subject: [PATCH 074/100] Split build_mod_settings into item collection and row construction --- src/hades2/mod_settings/mod_settings.cpp | 92 +++++++++++++++--------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 0cacc1f..9fa84c6 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2637,35 +2637,48 @@ namespace big::mod_settings // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render as // setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). - static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem, const std::string& section) - { - // A menu item is either a leaf setting directly in `section`, or a direct child group (a nested sub-section - // such as "config.biome_pool" while viewing "config"). - struct panel_item - { - bool is_group = false; - std::string key; // leaf key, or the group's last path segment - toml_v2::config_file::config_entry_base* entry = nullptr; // leaf only - std::string child_section; // group only (full menu path, e.g. "config.x.y") - std::string config_section; // the entry's REAL config section (for virtual I/O - group: its parent config section) - bool is_author_group = false; // group only: declared in configDesc `groups` (not a config section) - localized_text author_name; // author-group display name (is_author_group only) - localized_text author_description; // author-group description (is_author_group only) - bool has_order = false; - double order = 0.0; - int appearance = INT_MAX; // config.lua source rank (fallback order) - bool is_enabled = false; // the mod's master "enabled" toggle (root section only) - bool is_action = false; // a config.lua action button (runs a Lua callback, no config value) - action_info action; // valid when is_action - bool is_virtual = false; // a config.lua virtual row (Lua get/text/set, no config value) - bool virtual_interactive = false; // the virtual row has a `set` (an editable get/set widget) - }; + // A menu item is either a leaf setting directly in `section`, or a direct child group (a nested sub-section + // such as "config.biome_pool" while viewing "config"). + struct panel_item + { + bool is_group = false; + std::string key; // leaf key, or the group's last path segment + toml_v2::config_file::config_entry_base* entry = nullptr; // leaf only + std::string child_section; // group only (full menu path, e.g. "config.x.y") + std::string config_section; // the entry's REAL config section (for virtual I/O - group: its parent config section) + bool is_author_group = false; // group only: declared in configDesc `groups` (not a config section) + localized_text author_name; // author-group display name (is_author_group only) + localized_text author_description; // author-group description (is_author_group only) + bool has_order = false; + double order = 0.0; + int appearance = INT_MAX; // config.lua source rank (fallback order) + bool is_enabled = false; // the mod's master "enabled" toggle (root section only) + bool is_action = false; // a config.lua action button (runs a Lua callback, no config value) + action_info action; // valid when is_action + bool is_virtual = false; // a config.lua virtual row (Lua get/text/set, no config value) + bool virtual_interactive = false; // the virtual row has a `set` (an editable get/set widget) + }; + // One page of the Mods tab before any native widget exists: the rows in display order, plus what the row builder + // needs to know about the mod as a whole. + struct panel_contents + { std::vector items; - std::map groups; // child menu path -> group item (keeps its min appearance). - toml_v2::config_file::config_entry_base* enabled_entry = nullptr; + toml_v2::config_file::config_entry_base* enabled_entry = nullptr; // the mod's master "enabled" toggle toml_v2::config_file* view_cfg = nullptr; // this mod's config file (for child lookups) - const std::string section_prefix = section + "."; + bool mod_enabled = true; + }; + + // Collects every row belonging on page `section` of mod `stem` - settings, child groups, action buttons and virtual + // rows - and sorts them into display order. + static panel_contents collect_panel_items(const std::string& stem, const std::string& section) + { + panel_contents out; + std::vector& items = out.items; + + std::map groups; // child menu path -> group item (keeps its min appearance). + toml_v2::config_file*& view_cfg = out.view_cfg; // this mod's config file (for child lookups) + const std::string section_prefix = section + "."; // The author-declared menu groups (configDesc `groups`) - the categories a per-entry `group` can target that do // not exist as config sections. Looked up when a child group is created to pick its display name/order/source. @@ -2754,9 +2767,9 @@ namespace big::mod_settings // The mod's master switch lives in the root section track it whatever section is being shown, so nested // rows are greyed when the mod is disabled. - if (!enabled_entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) + if (!out.enabled_entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) { - enabled_entry = entry.get(); + out.enabled_entry = entry.get(); } // Hide config keys that carry no configDesc entry, so a mod's internal or bookkeeping values do not clutter its @@ -2857,12 +2870,12 @@ namespace big::mod_settings items.push_back(std::move(it)); } - const bool mod_enabled = !enabled_entry || enabled_entry->get_value_base(); - if (section == root_section && enabled_entry) + out.mod_enabled = !out.enabled_entry || out.enabled_entry->get_value_base(); + if (section == root_section && out.enabled_entry) { for (auto& it : items) { - if (it.entry == enabled_entry) + if (it.entry == out.enabled_entry) { it.is_enabled = true; } @@ -2895,7 +2908,16 @@ namespace big::mod_settings return a.appearance < b.appearance; // equal/absent order -> configDesc source order }); - for (const auto& it : items) + return out; + } + + // Builds the native widget for each collected row, in order, appending to g_rows. + static void build_panel_rows(MiscSettingsScreen* screen, const std::string& stem, const std::string& section, const panel_contents& contents) + { + const bool mod_enabled = contents.mod_enabled; + toml_v2::config_file* const view_cfg = contents.view_cfg; + + for (const auto& it : contents.items) { const bool is_enabled_row = it.is_enabled; const bool disabled = !is_enabled_row && !mod_enabled; @@ -3478,6 +3500,12 @@ namespace big::mod_settings } } + // One page of a mod's settings: collect what belongs on it, then build a native row for each. + static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem, const std::string& section) + { + build_panel_rows(screen, stem, section, collect_panel_items(stem, section)); + } + #pragma endregion #pragma region Panel sync, focus, and navigation From 69f126326fa1bf109a70edb9edcd0a3dca6756b9 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:45:55 +0100 Subject: [PATCH 075/100] Condense comments across the mod-settings files --- src/hades2/mod_settings/config_api.cpp | 51 ++- src/hades2/mod_settings/mod_settings.cpp | 396 +++++++++-------------- src/hades2/mod_settings/mod_settings.hpp | 44 +-- src/hades2/mod_settings/sgg_gui.hpp | 43 +-- 4 files changed, 209 insertions(+), 325 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 9891a3c..319a1e2 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -207,11 +207,10 @@ namespace big::mod_settings return std::string::npos; } - // Rank position for a described entry: where it appears in configDesc (the menu-layout table), so the menu's - // fallback order follows how the author laid out configDesc rather than the `config` defaults table. A config-backed - // key appears first in `config` (the defaults, defined before configDesc) and again in its configDesc entry, so we - // take the SECOND occurrence. A virtual row or action has no config default, so its only occurrence is already in - // configDesc. Returns npos (sorts last) when the key cannot be located, e.g. a numeric/bracketed key. + // Rank position for a described entry: where it appears in configDesc, so the menu's fallback order follows the + // author's layout rather than the `config` defaults table. A config-backed key appears in `config` first and again + // in configDesc, so the SECOND occurrence is taken; a virtual row or action only ever appears once. Returns npos + // (sorts last) when the key cannot be located, e.g. a numeric or bracketed key. static std::size_t desc_definition_offset(const std::string& src, const std::string& key, bool config_backed) { const std::size_t first = find_key_definition(src, key); @@ -573,9 +572,8 @@ namespace big::mod_settings m.context = parse_editable_context(desc["editableContext"], editable_context::any); // A field written as a Lua function is dynamic: skipped by the type-guarded reads above and re-evaluated at - // render by resolve_setting_metadata. Record that any is present so the menu knows to resolve. `hidden` is - // intentionally NOT dynamic (toggling it shifts layout, only re-done on a full rebuild - use `disabled` for a - // live condition), and `editableContext` is a fixed design property, so both stay static. + // render. `hidden` is deliberately not dynamic (toggling it shifts layout, so it is only re-done on a full + // rebuild - use `disabled` for a live condition), and `editableContext` is a fixed design property. for (const char* field : {"displayName", "description", "disabledDescription", "min", "max", "step", "values", "labels", "order", "disabled"}) { if (desc[field].get_type() == sol::type::function) @@ -664,10 +662,9 @@ namespace big::mod_settings return 1; // keep the single error value already on the stack. } - // Invokes a mod-supplied Lua callback protected, but with the silent handler above instead of ReturnOfModding's - // default. Our callers report failures themselves with one concise WARNING and recover, so a callback that - // legitimately fails in some contexts (e.g. reading run state from the main menu) does not also spam the console - // with an alarming ERROR plus full traceback. `fn` is taken by value so the caller's stored callback is untouched. + // Invokes a mod-supplied Lua callback protected, with the silent handler above rather than ReturnOfModding's + // default: callers report failures themselves with one concise WARNING, so a callback that legitimately fails in + // some contexts (e.g. reading run state from the main menu) does not also spam an ERROR plus full traceback. template static sol::protected_function_result call_mod_callback(sol::protected_function fn, Args&&... args) { @@ -824,10 +821,9 @@ namespace big::mod_settings return reserved.contains(key); } - // Walks a mod's configDesc (like collect_actions) collecting virtual rows and validating every entry. An entry must - // resolve to a config value (setting or group), an `action`, or an explicit `virtual = true` - anything else is - // logged as a likely author mistake (a described key missing from `config`). A `virtual` row with no `get`/`text` - // is logged too. + // Walks a mod's configDesc collecting virtual rows and validating every entry: it must resolve to a config value, an + // `action`, or an explicit `virtual = true`. Anything else is logged as a likely author mistake (usually a described + // key missing from `config`), as is a `virtual` row with no `get`/`text`. static void collect_virtual_rows(const std::string& guid, const sol::table& config_tbl, const sol::object& desc_obj, const std::string& section, std::vector& out) { if (desc_obj.is()) @@ -1011,11 +1007,10 @@ namespace big::mod_settings } } - // Routes toml_v2's config_entry::m_setting_changed (fired after a value changes and the file is saved) to a Lua - // onChanged callback, passing the new value and the key. Fires for any edit made through our options menu (main menu - // or in a save, gated by on_change_callbacks_enabled), but not from a mod's own config write outside the menu. A - // same-value write is a no-op, so a callback that writes back cannot loop. Stored on the entry (owned by the mod's - // config_file, destroyed with the Lua state on App::Reset), so the captured sol reference never dangles. Called protected. + // Routes toml_v2's m_setting_changed (fired after a value changes and the file is saved) to a Lua onChanged + // callback. Fires for edits made through the options menu, but not a mod's own config write outside it. A same-value + // write is a no-op, so a callback that writes back cannot loop. Stored on the entry, which the mod's config_file + // owns and which dies with the Lua state, so the captured sol reference never dangles. static void attach_on_change(toml_v2::config_file::config_entry_base* entry, sol::protected_function callback) { if (!entry || !callback.valid()) @@ -1321,10 +1316,9 @@ namespace big::mod_settings setting_metadata meta; }; - // Recursively binds a config.lua `defaults` table into `cf` under `section` (nested tables become sub-sections). - // A leaf with a rich-table description has its metadata extracted into `meta_out`, and any leaf with a configDesc - // entry is recorded in `described_out` so the menu can hide undescribed keys. bind adopts a value already in the - // .cfg (preserving user edits) under section "config", keeping it byte-compatible with SGG_Modding-Chalk. + // Recursively binds a config.lua `defaults` table into `cf` under `section`, nested tables becoming sub-sections. + // bind adopts a value already in the .cfg (preserving user edits) under section "config", keeping the file + // byte-compatible with SGG_Modding-Chalk. static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector& meta_out, std::vector>& defaults_out, std::vector>& described_out) { sol::table desc_tbl; @@ -2011,10 +2005,9 @@ namespace big::mod_settings g_described_keys.clear(); } - // The config object handed to mods is a plain Lua table (so `type(config) == "table"`, matching Chalk), driven - // by one shared metatable that reproduces Chalk's metamethod surface: index/new_index read/write entries, and - // len/pairs/ipairs (plus ModUtil's next/inext via rawget(getmetatable(t), '__next'/'__inext')) make it iterable. - // Each wrapper's (cf, section) live in weak-keyed registry maps, so the wrapper stays empty and is collected with it. + // The config object handed to mods is a plain Lua table (so `type(config) == "table"`, matching Chalk) driven by + // one shared metatable reproducing Chalk's metamethod surface, plus ModUtil's next/inext. Each wrapper's + // (cf, section) live in weak-keyed registry maps, so the wrapper stays empty and is collected with it. sol::table proxy_metatable = state.create_table(); proxy_metatable["__index"] = &proxy_index; proxy_metatable["__newindex"] = &proxy_new_index; diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 9fa84c6..23217a4 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -35,14 +35,11 @@ namespace big::mod_settings using sgg::MiscSettingsScreen; using sgg::Vec2; - // Hades II's in-game options menu is the native C++ screen sgg::MiscSettingsScreen. Option rows are native - // GUIComponentButtons built here. GUIComponent::mName lives at this offset. It is an eastl::string used by - // ApplyDataToComponent to look up the matching template. + // GUIComponent::mName, an eastl::string ApplyDataToComponent uses to look up the matching sjson template. static constexpr std::size_t gui_component_name_offset = 0x4'88; - // Each GUIComponent embeds an sgg::ComponentData (mData) whose mDef (sgg::ComponentDataDef) drives its - // visuals/layout. Retuning mDef then re-running ComponentData::SetupComponent re-applies the template - this is how - // a plain button is converted into a key-rebind style text row. Offsets validated against the Ship Hades2.pdb. + // Retuning mDef then re-running ComponentData::SetupComponent re-applies the template - this is how a plain button + // is converted into a key-rebind style text row. static constexpr std::size_t component_data_offset = 0x88; // GUIComponent::mData (sgg::ComponentData). static constexpr std::size_t component_def_offset = 0xA8; // mData(0x88) + ComponentData::mDef(0x20). @@ -61,9 +58,9 @@ namespace big::mod_settings static constexpr std::size_t def_graphic = 0x80; // mGraphic (HashGuid) static constexpr std::size_t def_selected_graphic = 0x84; // mSelectedGraphic (HashGuid) static constexpr std::size_t def_alternate_graphic = 0x88; // mAlternateGraphic (HashGuid) - // SoundCue def fields (each sgg::SoundCue is 0x10 bytes: pOwner @0, mName HashGuid id @8). The base OnClicked plays - // mPressSound. The native toggle handler ToggleOptionValueChanged (which our C++ toggle path replaces) is what - // plays mToggleOnSound/mToggleOffSound, so we copy the matching one into mPressSound to reproduce the sound. + // Each sgg::SoundCue is 0x10 bytes (pOwner @0, mName HashGuid @8). The base OnClicked plays mPressSound, while the + // native ToggleOptionValueChanged (which our toggle path replaces) plays the toggle cues - so we copy the matching + // one into mPressSound to reproduce the sound. static constexpr std::size_t def_press_sound = 0x1'B0; // mPressSound (sgg::SoundCue) static constexpr std::size_t def_toggle_on_sound = 0x1'E0; // mToggleOnSound (sgg::SoundCue) static constexpr std::size_t def_toggle_off_sound = 0x1'F0; // mToggleOffSound (sgg::SoundCue) @@ -82,13 +79,11 @@ namespace big::mod_settings static constexpr std::size_t def_spacing = 0x1'5C; // mSpacing (float) row pitch, read by UpdateScrollState static constexpr std::size_t def_fade_speed = 0x2'1C; // mFadeSpeed (float) opacity ease rate (component +0x2C4) - // Opacity ease rate applied to every row so all row types fade at one uniform speed. GUIComponent::Update moves - // mFadeOpacity toward mFadeTarget by dt * mFadeSpeed each frame, so this drives the fade timing. + // GUIComponent::Update moves mFadeOpacity toward mFadeTarget by dt * mFadeSpeed, so this drives the fade timing. + // Applied to every row so all row types fade at one uniform speed. static constexpr float row_fade_speed = 10.0f; - // Native sgg::MessageDialog (the single-button message box the game shows in the MAIN MENU for save/file. Errors, - // ShellText SaveErrorPC/FileAccessErrorPC). Unlike the Lua screen system it does not need a loaded save, so it - // works when mods are toggled in the main menu. Offsets + RVAs. DIA-validated against the current Ship Hades2.pdb. + // sgg::MessageDialog, the single-button box the game uses in the MAIN MENU for save/file errors. static constexpr std::size_t message_dialog_size = 0x2'F0; // sizeof sgg::MessageDialog static constexpr std::size_t screen_manager_offset = 0x48; // sgg::GameScreen::mScreenManager static constexpr std::size_t screen_removed_offset = 0x21; // sgg::GameScreen::mRemoved (bool) @@ -98,9 +93,8 @@ namespace big::mod_settings static constexpr std::size_t dialog_confirm_button_offset = 0x1'A0; // sgg::MenuScreen::mConfirmButton static constexpr std::size_t dialog_message_offset = 0x2'B0; // sgg::MessageDialog::mMessageText - // The MessageDialog.sjson MessageText template renders at FontSize 26, which is larger than we want for the multi-line - // body. The rendered size is driven by GUIComponentTextBox::mFontHandle (@0x6A4). Scaling its mFontSizeRatio - // (@+0x0C)/mEnglishFontSizeRatio (@+0x10) shrinks it. + // The MessageDialog.sjson MessageText template renders at FontSize 26, too large for the multi-line body. Scaling + // the font handle's ratios shrinks it. static constexpr std::size_t textbox_font_handle_offset = 0x6'A4; // GUIComponentTextBox::mFontHandle static constexpr std::size_t font_handle_size_ratio_offset = 0x0C; // sgg::FontHandle::mFontSizeRatio static constexpr std::size_t font_handle_eng_size_ratio_offset = 0x10; // sgg::FontHandle::mEnglishFontSizeRatio @@ -112,9 +106,8 @@ namespace big::mod_settings static constexpr std::uintptr_t message_dialog_ctor_rva = 0x16'EE'60; // sgg::MessageDialog::MessageDialog static constexpr std::uintptr_t add_screen_rva = 0x14'7D'D0; // sgg::ScreenManager::AddScreen - // tf_new_internal: the game's own factory that allocates a - // GUIComponentNumBox, sets its vtable and builds its 5 sub-components (box graphic, label, value text, left/right - // arrows). Template instantiation, so resolved by RVA off the anchor. + // tf_new_internal: the game's own factory, which allocates the + // num-box and builds its 5 sub-components. A template instantiation, so resolved by RVA off the anchor. static constexpr std::uintptr_t numbox_factory_rva = 0x17'A5'30; // eastl::vector::push_back, used only as a fallback when the named PDB symbol is missing (it is @@ -122,16 +115,14 @@ namespace big::mod_settings static constexpr std::uintptr_t push_back_rva = 0x14'1E'D0; // sgg::MenuScreen::TeleportCursorTo(this, GUIComponent*) - the 2-arg overload that drops the controller/keyboard - // free-form cursor onto a component. Addressed by RVA off the anchor. + // free-form cursor onto a component. static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; - // The config/control GLOBALS below (ConfigOptions::UseMouse/ConfigOptions::Language/Controls::Cancel/ - // Controls::Select) used to be addressed by RVA off the anchor too, but they live in .data/.rdata, which a game update - // can grow and shift independently of .text, so an anchor-relative RVA cannot be trusted for them. They are named PDB - // globals, so they are now resolved by name (update-proof) - see set_up_hooks. + // The config/control globals (ConfigOptions::UseMouse/Language, Controls::Cancel/Select) live in .data/.rdata, + // which a game update can grow and shift independently of .text, so they are resolved by name rather than by an + // anchor-relative RVA. - // sgg::GUIComponentNumBox field offsets (DIA-validated on the current Ship build) sizeof 0x5D0. Derives directly - // from GUIComponent (not GUIComponentButton). + // sgg::GUIComponentNumBox, sizeof 0x5D0. Derives directly from GUIComponent, not GUIComponentButton. static constexpr std::size_t numbox_value_offset = 0x5'40; // mNumberValue (float) static constexpr std::size_t numbox_step_offset = 0x5'44; // mNumberStepValue (float) static constexpr std::size_t numbox_min_offset = 0x5'48; // mNumberMin (float) @@ -157,31 +148,27 @@ namespace big::mod_settings static constexpr std::size_t component_def_scale_y_offset = 0x1'18; // mData.mDef.mScaleY (float) - // FreeFormSelectOffset is added to a component's location when the spatial keyboard/controller nav (SearchInDirection) - // evaluates it as a candidate. We use it to place the scroll arrows' eval point where the next or previous row would - // be, so the nav reaches an arrow at a page edge and its auto-activate fires the pager (see - // enable_arrow_keyboard_paging). + // FreeFormSelectOffset is added to a component's location when the spatial keyboard/controller nav + // (SearchInDirection) evaluates it as a candidate. Used to place the scroll arrows' eval point where the next or + // previous row would be, so nav reaches an arrow at a page edge (see enable_arrow_keyboard_paging). static constexpr std::size_t component_free_form_offset_x_offset = 0x1'54; // mFreeFormSelectOffsetX (float) static constexpr std::size_t component_free_form_offset_y_offset = 0x1'58; // mFreeFormSelectOffsetY (float) static constexpr std::size_t component_auto_activate_offset = 0x00'BC; // mAutoActivateWithGamepad (bool) - // mData.mDef.mFreeFormSelectable: the spatial keyboard/controller nav (SearchInDirection) skips any candidate whose - // byte here is false, before it even calls IsSelectable. Mouse hover (MenuScreen::UpdateMouseOver) does not read it, - // so clearing it makes DOWN/UP nav jump over a row while the mouse can still hover it (to read its description). + // SearchInDirection skips a candidate whose mFreeFormSelectable is false before it even calls IsSelectable, while + // mouse hover does not read it - so clearing it makes UP/DOWN nav jump a row that the mouse can still hover. static constexpr std::size_t component_free_form_selectable_offset = 0x00'B1; // mData.mDef.mFreeFormSelectable (bool) // The Button_Secondary sprite's native atlas width in px. The box draws at native * mScale * mScaleX. static constexpr float button_graphic_native_width = 350.0f; - // Approximate label capacity (in measure_width glyph units) of the box at its native width. Padding is kept around - // the label. A label wider than this stretches. The box just enough to fit, so short buttons keep the clean native - // box and only long ones widen (mild end-cap distortion). + // Approximate label capacity of the box at its native width, in measure_width glyph units. A longer label stretches + // the box just enough to fit, so short buttons keep the clean native box and only long ones distort. static constexpr float button_label_capacity = 15.0f; static constexpr float button_label_padding = 2.0f; - // sgg::GUIComponentSlider (the audio-volume drag bar). DIA-validated on the current Ship build, sizeof 0x5B0. - // DoShowCategory hand-builds it, so make_slider_row does too. ??_7GUIComponentSlider@sgg@@6B@ is preferred by - // name. This RVA is only a .rdata fallback and must be refreshed when the build changes. + // sgg::GUIComponentSlider, the audio-volume drag bar. DoShowCategory hand-builds it, so make_slider_row does too. + // The vtable is preferred by name; this RVA is a .rdata fallback and must be refreshed when the build changes. static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'68; static constexpr std::size_t slider_sizeof = 0x5'B0; static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking/mFill) @@ -197,9 +184,8 @@ namespace big::mod_settings static constexpr std::size_t slider_value_text_offset = 0x5'98; // mValueTextBox (GUIComponentTextBox*, right value) static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) - // GUIComponentSlider has no Draw-time highlight gate (unlike GUIComponentButton, whose Draw re-derives its highlight - // from mForceSelected/owner->mSelectedComponent). mFocused is the slider's own bool the focus look tracks - // mUseSelectedTextColor is the green-text flag on a child GUIComponentTextBox (the left label/right value). + // GUIComponentSlider has no Draw-time highlight gate (unlike GUIComponentButton, whose Draw re-derives it from + // mForceSelected/owner->mSelectedComponent), so the focus look tracks its own mFocused bool. static constexpr std::size_t slider_focused_offset = 0x5'48; // GUIComponentSlider::mFocused (bool) static constexpr std::size_t textbox_use_selected_color_off = 0x5'52; // GUIComponentTextBox::mUseSelectedTextColor static constexpr std::size_t vtable_on_mouse_off_offset = 0x00'60; // GUIComponent::OnMouseOff slot @@ -207,16 +193,12 @@ namespace big::mod_settings static constexpr std::size_t vtable_on_focus_off_offset = 0x1'18; // GUIComponent::OnFocusOff slot static constexpr std::size_t vtable_set_location_offset = 0x1'80; // GUIComponent::SetLocation slot (moves the component and its children) - // Disabled-greying of a slider/num-box, which are multi-sub-component widgets: the button-style def text greying does - // not reach their separate label/value text boxes or their bar/arrow graphics, so each is greyed directly. A - // GUIComponentTextBox renders its mDisabledText colour when mUseDisabledTextColor is set (Slider/NumBox Draw set it on - // the LABEL each frame from mIsUseable, but only if the box's def carries a non-negative disabled colour, so we write - // that colour explicitly and also flag the value box, which Draw never touches). A GUIComponentImage (slider bar) - // tints from mColor every frame, so writing mColor (and mColorTarget so a lerp does not undo it) dims it. + // Greying a slider/num-box: the button-style def greying does not reach their separate label/value text boxes or + // bar/arrow graphics, so each is greyed directly. A text box renders mDisabledText only when its def carries a + // non-negative disabled colour, so that is written explicitly; the value box is flagged too, since Draw never + // touches it. An image tints from mColor every frame, so mColorTarget is written as well or a lerp undoes it. static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor - // Normal (0x1B4) and selected (0x1D0) text colours on the child text box, from the component def (component_def_offset - // 0xA8 + def_text_red 0x10C/def_sel_text_red 0x128). Greying these two as well keeps the label grey in every state - - // matches how set_def_text_grey greys a button row's own def. + // Greying the normal and selected colours too keeps the label grey in every state, matching set_def_text_grey. static constexpr std::size_t textbox_text_red = 0x1'B4; // mData.mDef.mTextRed (float) static constexpr std::size_t textbox_selected_text_red = 0x1'D0; // mData.mDef.mSelectedTextRed (float) static constexpr std::size_t textbox_disabled_text_red = 0x1'E8; // mData.mDef.mDisabledTextRed (float) @@ -230,20 +212,17 @@ namespace big::mod_settings static constexpr std::size_t def_sel_red = 0xFC; // ComponentDataDef::mSelectedRed - set <0 to disable the selected-colour override in Draw/On(Un)Selected static constexpr float disabled_text_grey = 0.22f; // matches set_def_text_grey (toggle/text rows) static constexpr std::uint32_t disabled_graphic_grey = 0xFF'66'66'66; // opaque 0.4 grey (packed A,B,G,R) - // GUIComponentTextBox::SetTextColor is vtable slot +0x160. The template caches a bright colour at build, and greying - // the def alone does not update it, so a still-selectable greyed widget label stays bright - we re-apply this grey - // through SetTextColor instead (the same call MiscSettingsScreen::UpdateButtonStates uses to grey a still-hoverable - // option). + // The template caches a bright colour at build time and greying the def alone does not update it, so a still- + // selectable greyed label stays bright - SetTextColor re-applies the grey, as UpdateButtonStates does. static constexpr std::size_t vtable_set_text_color_offset = 0x1'60; static constexpr std::uint32_t disabled_label_grey_packed = 0xFF'38'38'38; - // A GUIComponentAnimation (the num-box's box/frame graphic) tints from its own mColor. NumBox::OnSelected turns the - // box black by writing the selected colour here (opaque black for the OptionNumBox template). + // NumBox::OnSelected turns the box black by writing the selected colour into the animation's own mColor. static constexpr std::size_t animation_color_offset = 0x5'58; // GUIComponentAnimation::mColor (packed ARGB) static constexpr std::uint32_t numbox_hover_bg_black = 0xFF'00'00'00; // the num-box's hovered/selected box colour - // Scalar deleting destructor slot in the GUIComponent vtable. Called with flags=0 it destructs and frees any owned - // sub-components without the final operator delete, so we then. _aligned_free. + // Called with flags = 0 it destructs and frees owned sub-components without the final operator delete, so the block + // itself is freed separately (see game_free). static constexpr std::size_t vtable_deleting_dtor_offset = 0x1'88; using ctor_fn = void* (*)(void* button, void* owner_screen); @@ -543,11 +522,9 @@ namespace big::mod_settings // every change ticked down in the Update hook. static float g_dynamic_refresh_settle = 0.0f; - // Navigation restore stack: one entry per drill-in level (the mod list into a mod, or a section into a child group) - // Each records the parent view's scroll offset and the identity of the row drilled through, so backing out restores - // that scroll and re-selects that row instead of snapping to the top focus_stem identifies a mod row (returning to the - // mod list) focus_section identifies a group row by its target section (returning to a parent section) - // g_pending_restore holds the entry popped by the current back-navigation for build_panel to consume. + // Navigation restore stack: one entry per drill-in level. Each records the parent view's scroll offset and which row + // was drilled through, so backing out restores that scroll and re-selects that row instead of snapping to the top. + // focus_stem identifies a mod row, focus_section a group row by its target section. struct NavRestore { std::uint32_t scroll_index = 0; @@ -625,13 +602,10 @@ namespace big::mod_settings return !custom.empty() ? custom : opt_out_note(); } - // Escapes the characters the game's text parser (GUIComponentTextBox::Parse) treats as markup, so arbitrary user text - // - config values (e.g. Windows paths with '\'), display names and descriptions - renders verbatim instead of being - // mangled. The parser reads '\' as an escape lead that consumes the following word ("D:\Program..." -> "D: ...") and - // '[' ']' as inline-tag delimiters whose contents are dropped ("[deprecated] x" -> " x"). A leading backslash makes - // each literal (\\ -> \, \[ -> [, \] -> ]) backslash MUST be escaped first ('{' and '@' are also markup leads but have - // no literal escape in the parser, so are left as-is - they are rare in config text and, unlike '\'/'[', do not - // silently eat surrounding characters.). + // Escapes the characters GUIComponentTextBox::Parse treats as markup, so arbitrary user text renders verbatim. + // The parser reads '\' as an escape lead that consumes the following word ("D:\Program..." -> "D: ...") and '[' ']' + // as inline-tag delimiters whose contents are dropped ("[deprecated] x" -> " x"). Backslash must be escaped first. + // '{' and '@' are also markup leads but have no literal escape and do not eat surrounding characters, so are left. static std::string escape_markup(const std::string& text) { std::string out; @@ -928,11 +902,10 @@ namespace big::mod_settings g_set_normal_texture(row, is_on ? on_hash : off_hash, false); } - // Reproduces the vanilla toggle click sound. A native ConfigOptions toggle plays mToggleOnSound/mToggleOffSound from - // its ValueChanged handler (MiscSettingsScreen::ToggleOptionValueChanged), which our C++ toggle path replaces, so a - // toggle would otherwise be silent (the base GUIComponent::OnClicked only plays mPressSound, which the - // OptionToggleButton template leaves unset). We copy the cue for the value the click will produce into mPressSound - // just before the base OnClicked runs, so its own audio path plays it with the correct swap handling. + // Reproduces the vanilla toggle click sound. A native toggle plays its cue from ToggleOptionValueChanged, which our + // toggle path replaces, and the base OnClicked only plays mPressSound (unset in the toggle template) - so a toggle + // would be silent. Copying the cue for the value the click will produce into mPressSound lets the native audio path + // play it with the correct swap handling. static void stage_toggle_press_sound(GUIComponent* row, bool new_value) { char* def = reinterpret_cast(row) + component_def_offset; @@ -994,12 +967,10 @@ namespace big::mod_settings *reinterpret_cast(b + image_color_target_offset) = disabled_graphic_grey; } - // Greys a disabled toggle's on/off ring so it reads greyed from frame one. The ring (mNormalTexture) is a bare texture - // id with no colour of its own - GUIComponentButton::Draw paints it with mButtonColor@0x55C, which starts black and - // the engine only eases to the greyed mColorTarget@0x78 in Update/on selection, so an untouched disabled toggle shows - // black until a hover eases it grey. We set the live paint colour AND the ease target to the disabled grey (so Update - // sees them equal and never eases away), and set the def's mSelectedRed < 0 so Draw/On(Un)Selected skip the - // selected-colour override - the ring then reads greyed at rest and stays greyed through hover/selection. + // Greys a disabled toggle's on/off ring so it reads greyed from frame one. The ring is a bare texture that Draw + // paints with mButtonColor, which starts black and only eases toward mColorTarget on hover - so an untouched + // disabled toggle would show black. Setting both to the grey (Update sees them equal and never eases) plus + // mSelectedRed < 0 to skip the selected-colour override keeps it greyed at rest and through hover. static void grey_toggle_graphic(GUIComponent* row) { char* b = reinterpret_cast(row); @@ -1244,11 +1215,9 @@ namespace big::mod_settings g_disable(row); } - // The CategoryOptionsButton template is shared with the top category tabs (paged by bumpers, not the vertical option - // nav), so it leaves mData.mDef.mFreeFormSelectable unset - meaning the up/down spatial nav (SearchInDirection) skips - // it. Opt an enabled action button in, and give it a wide option-column nav rect (install_wide_button_nav_rect): its - // native GetArea is a narrow rect at the centered label, which a vertical nav ray down the option column never - // crosses, so nav would still skip it. + // The CategoryOptionsButton template is shared with the top category tabs (paged by bumpers, not the vertical + // nav), so it leaves mFreeFormSelectable unset and SearchInDirection skips it. Opt an enabled action button in, + // and give it a wide nav rect too, since its native GetArea is a narrow rect at the centred label. if (!disabled) { *reinterpret_cast(row_bytes + component_free_form_selectable_offset) = true; @@ -1347,12 +1316,9 @@ namespace big::mod_settings } } - // Builds a native sgg::GUIComponentNumBox stepper row - identical to the game's own FPS-limit/graphics-quality options - // (boxed value flanked by Arrow_Left/Arrow_Right, left/right + arrow-click stepping, keyboard + controller). The - // game's factory allocates it, sets the correct vtable and builds all five sub-components (box graphic, label, value - // text, both arrows), which are also freed automatically when the row vectors are torn down - so no manual cleanup is - // needed. Value edits are persisted by the SetNumberValue hook (filtered to our rows). Returns the num-box component - // (not a GUIComponentButton, so it never routes through the OnClicked hook). + // A native num-box stepper row, as used by the game's own FPS-limit and graphics-quality options. The game's factory + // allocates it and builds all five sub-components, which the row teardown frees with it. Value edits are persisted + // by the SetNumberValue hook. Not a GUIComponentButton, so it never routes through the OnClicked hook. static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled, const std::vector* value_labels = nullptr, bool block_input = true) { if (!g_numbox_factory || !g_numbox_set_range || !g_numbox_set_value || !g_apply_data || !g_show_text) @@ -1431,11 +1397,9 @@ namespace big::mod_settings if (disabled) { - // mDisableInput is the num-box's own input gate (its HandleInput early-outs on it), blocking both the arrow-clicks - // and keyboard/controller stepping - mIsUseable does NOT gate num-box input, so it is always set on a disabled box. - // Grey the label and value boxes (grey_text_box also greys their normal/selected colours so a still-selectable box - // stays greyed and does not highlight on hover). When block_input is set (the whole-mod-off case) also clear - // mIsUseable so nav/hover skip it and force the box to the hovered black so it reads consistently. + // mDisableInput is the num-box's own input gate, blocking arrow-clicks and keyboard stepping alike; + // mIsUseable does NOT gate num-box input. When block_input is set (the whole-mod-off case) clear mIsUseable + // too so nav and hover skip the row entirely. *reinterpret_cast(nb_bytes + numbox_disable_input_offset) = true; grey_text_box(*reinterpret_cast(nb_bytes + numbox_label_text_offset)); grey_text_box(*reinterpret_cast(nb_bytes + numbox_value_text_offset)); @@ -1502,13 +1466,11 @@ namespace big::mod_settings } } - // Row-sized hover/nav hit rect for our custom rows whose native GetArea is unsuitable, installed via a patched vtable - // on the GetArea (+0x98) and GetScreenArea (+0xA0) slots. Two rows need it: interactive sliders (whose native - // GUIComponentSlider::GetArea unions the bar/fill/label/value sub-components into a near screen-spanning rectangle - // that steals mouse hover from every other row via the nearest-anchor tiebreak in MenuScreen::UpdateMouseOver), and - // centered action buttons (whose GUIComponentButton::GetArea is derived from the CENTERED label, a narrow rect at the - // button centre that a vertical nav ray down the option column never crosses, so the up/down nav skips them). Slider - // dragging is unaffected: it runs through GUIComponentSlider::HandleInput (hooked separately), not GetArea. + // Row-sized hit rect for rows whose native GetArea is unsuitable, installed via a patched vtable on the GetArea and + // GetScreenArea slots. Two rows need it: sliders (whose GetArea unions their sub-components into a near + // screen-spanning rect that steals hover from every other row) and centred action buttons (whose GetArea is a narrow + // rect at the button centre that a vertical nav ray never crosses). Slider dragging runs through HandleInput, not + // GetArea, so it is unaffected. static void* row_bounded_area(GUIComponent* self, std::int32_t* out) { const int left = static_cast(row_location_x + row_text_offset_x); // option-name column start (~660) @@ -1543,11 +1505,9 @@ namespace big::mod_settings *reinterpret_cast(row) = g_button_vtable_patched; } - // Builds a native sgg::GUIComponentSlider row (the volume-style horizontal drag bar) for a bounded numeric setting. - // The slider stores a normalized 0..1 fraction: we map the setting's [min,max] onto it and snap drags to `step` in the - // SetFraction hook. The engine exposes no factory for this type, so this replicates the construction DoShowCategory - // performs for the volume rows and names the row "OptionSlider" so ApplyDataToComponent applies the matching sjson - // template. + // A native slider row (the volume-style drag bar) for a bounded numeric setting. The slider stores a normalized + // 0..1 fraction, so [min,max] is mapped onto it and drags are snapped to `step` in the SetFraction hook. The engine + // exposes no factory for this type, so this replicates what DoShowCategory does for the volume rows. static GUIComponent* make_slider_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool show_as_pct, bool is_pct, bool disabled, bool block_input = true) { if (!g_gui_component_ctor || !g_image_ctor || !g_textbox_ctor || !g_slider_defaults || !g_slider_set_fraction || !g_slider_vtable || !g_apply_data || !g_show_text) @@ -2590,10 +2550,8 @@ namespace big::mod_settings } // Resolves an entry's menu path: its `group` override (validated against author groups and the mod's config - // sections) else its config section. A `group` naming neither is logged once (per stem+path) and falls back to the - // config-section placement, so a typo leaves the row where its value lives rather than stranding it in a bogus - // group. Shared by the panel builder and Reset so both agree on where an entry lives. `view_cfg` is the mod's - // config file (may be null, in which case only author groups are accepted). + // sections) else its config section. A `group` naming neither is logged once and falls back to the config-section + // placement, so a typo leaves the row where its value lives. Shared by the panel builder and Reset so both agree. static std::string resolve_entry_menu_path(const std::string& stem, const std::vector& author_groups, toml_v2::config_file* view_cfg, const std::string& csection, const std::vector& group) { if (group.empty()) @@ -2680,21 +2638,17 @@ namespace big::mod_settings toml_v2::config_file*& view_cfg = out.view_cfg; // this mod's config file (for child lookups) const std::string section_prefix = section + "."; - // The author-declared menu groups (configDesc `groups`) - the categories a per-entry `group` can target that do - // not exist as config sections. Looked up when a child group is created to pick its display name/order/source. + // The categories a per-entry `group` can target that do not exist as config sections. const std::vector author_groups = mod_menu_groups(stem); - // Resolves an entry's menu path: its `group` override (validated) else its config section. Delegates to the - // shared resolver so the panel and Reset agree on placement. + // Delegates to the shared resolver so the panel and Reset agree on placement. auto resolve_menu_path = [&](const std::string& csection, const std::vector& group) -> std::string { return resolve_entry_menu_path(stem, author_groups, view_cfg, csection, group); }; - // Where an entry (living in config section `csection`, with an optional `group` override) sits relative to the - // current view `section`: 0 = not on this page (skip), 1 = a direct row here, 2 = inside a child group (its full menu - // path returned in child_out). The entry's menu path is its `group` override else its config section, so a flat - // config can be regrouped and a nested one re-nested without moving the actual config value. + // Where an entry sits relative to the current view: 0 = not on this page, 1 = a direct row here, 2 = inside a + // child group (its full menu path returned in child_out). auto placement = [&](const std::string& csection, const std::vector& group, std::string& child_out) -> int { const std::string m = resolve_menu_path(csection, group); @@ -2711,9 +2665,8 @@ namespace big::mod_settings return 0; }; - // Creates (or ranks lower) the child group row at menu path `child_path`. A group declared in configDesc - // `groups` (find_author_group) takes its name/order/description from there. Otherwise it is a config-derived - // group whose metadata comes from its configDesc entry at the matching config section (resolved in the render). + // Creates (or ranks lower) the child group row at `child_path`. A group declared in configDesc `groups` takes + // its name/order/description from there; otherwise it is config-derived and resolved in the render. auto ensure_group = [&](const std::string& child_path, int app) { if (const auto git = groups.find(child_path); git != groups.end()) @@ -2742,9 +2695,8 @@ namespace big::mod_settings } else if (const auto meta = resolved_metadata(stem, section, g.key); meta && meta->has_order && !config_child_exists(view_cfg, child_path, "order")) { - // Config-derived group: its menu path equals its config section and the view is its parent section, so - // its metadata is configDesc.
. (resolved here for order, and again in the render for the - // name/description). Defer the order to a real config child named "order" (see config_child_exists). + // Config-derived group: its metadata is configDesc.
., resolved here for order and again + // in the render for name/description. Defers to a real config child named "order". g.has_order = true; g.order = meta->order; } @@ -2765,24 +2717,22 @@ namespace big::mod_settings continue; } - // The mod's master switch lives in the root section track it whatever section is being shown, so nested - // rows are greyed when the mod is disabled. + // Tracked whatever section is shown, so nested rows are greyed when the mod is disabled. if (!out.enabled_entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) { out.enabled_entry = entry.get(); } - // Hide config keys that carry no configDesc entry, so a mod's internal or bookkeeping values do not clutter its - // settings page. The one exception is the master "enabled" toggle, always shown so the mod stays toggleable even - // when its author did not describe it. + // Undescribed keys are a mod's internal bookkeeping, so they stay off the page. The master "enabled" + // toggle is the exception, always shown so the mod stays toggleable even if undescribed. const bool is_enabled_toggle = key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key); if (!is_enabled_toggle && !setting_is_described(stem, key.m_section, key.m_key) && !entry_has_description(entry.get())) { continue; } - // The `group` override is a static field, so the cheap (no-Lua) stored metadata resolves the entry's - // menu placement. Everything else (order, name, widget) still uses the entry's real config section. + // `group` is a static field, so the cheap (no-Lua) stored metadata is enough to place the entry. + // Everything else still uses its real config section. const auto static_meta = get_setting_metadata(stem, key.m_section, key.m_key); const std::vector grp = static_meta ? static_meta->group : std::vector{}; std::string child_path; @@ -2813,8 +2763,8 @@ namespace big::mod_settings items.push_back(std::move(kv.second)); } - // Action buttons (config.lua `action` entries). Collected across ALL config sections (empty section = all) and - // bucketed by menu path, so an action moved with `group` lands on its target page like any setting. + // Collected across ALL config sections (empty section = all) and bucketed by menu path, so an action moved with + // `group` lands on its target page like any setting. for (auto& a : get_actions(stem, "")) { std::string child_path; @@ -2839,9 +2789,8 @@ namespace big::mod_settings items.push_back(std::move(it)); } - // Virtual rows (config.lua `virtual = true` entries) - non-config rows whose value comes from Lua callbacks. - // Collected across all sections and bucketed by menu path, interleaved with the settings by `order`/source - // rank. A dynamic field on a row that lands on THIS page makes an edit re-run this build (live refresh). + // Interleaved with the settings by `order`/source rank. A dynamic field on a row that lands on THIS page makes + // an edit re-run this build, for live refresh. for (const auto& vr : get_virtual_rows(stem, "")) { std::string child_path; @@ -2987,8 +2936,8 @@ namespace big::mod_settings continue; } - // A virtual row (config.lua `virtual = true`): a menu row whose value comes from Lua callbacks, not a config entry. - // There is no `hidden`: a virtual row has no backing state, so to omit it the author does not declare it. + // A virtual row's value comes from Lua callbacks, not a config entry. There is no `hidden`: with no backing + // state, an author omits one by not declaring it. if (it.is_virtual) { // A `group` override can move a virtual row onto a page whose path differs from its config section, so @@ -3212,9 +3161,8 @@ namespace big::mod_settings continue; } - // A nested group drills into its child menu path when clicked/activated. A config-derived group takes its - // display name/description from its configDesc entry (its menu path equals its config section). An author - // group (configDesc `groups`) carries its own name/description captured during collection. + // A nested group drills into its child menu path when activated. A config-derived group takes its name and + // description from its configDesc entry; an author group carries its own, captured during collection. if (it.is_group) { std::string glabel; @@ -3277,17 +3225,14 @@ namespace big::mod_settings continue; } - // An author may mark a setting `disabled` (statically or via a dynamic function): the row stays visible but is shown - // read-only and greyed (e.g. a cap that only applies while its parent fix is on). Distinct from the mod-disabled - // greying (whole panel off), which keeps the native widgets. + // Author-`disabled` keeps the row visible but read-only and greyed. Distinct from the whole-mod-off greying, + // which keeps the native widgets. const bool author_disabled = meta && meta->disabled; const std::string mname = meta ? resolve_localized(meta->name) : std::string{}; const std::string label = escape_markup(!mname.empty() ? mname : key_to_display(key)); - // An enum (metadata `values`) renders as a native number box cycling its label list. A numeric setting with - // author-declared min AND max renders as a native number box over its range (like the FPS-limit option) UNLESS the - // author set `freetext` (e.g. for a very large range better typed than stepped) other numbers stay freetext-editable - // with a plain right-column value label. + // An enum cycles its label list in a num-box; a bounded number gets a slider unless the author set `freetext` + // (better for a very large range). Everything else is a freetext-editable right-column value. const bool is_number = entry->type() == typeid(double); const bool is_enum = meta && !meta->values.empty(); const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max && !meta->freetext; @@ -3333,9 +3278,8 @@ namespace big::mod_settings bool built_enum = false; bool built_toggle = false; - // A setting that is unavailable in the current context (editable_context mismatch) or that the author marked - // `disabled` is shown read-only: its current value in a greyed key+value row that still takes focus, so the - // description box can explain why. Edits are blocked by pr.disabled in the row handlers. + // A context-blocked or author-disabled setting still takes focus, so the description box can explain why it + // is unavailable. Edits are blocked by pr.disabled in the row handlers. const editable_context ctx = effective_editable_context(meta, is_enabled_row); const bool context_blocked = is_context_restricted(ctx); if (!disabled && (context_blocked || author_disabled)) @@ -3510,11 +3454,9 @@ namespace big::mod_settings #pragma region Panel sync, focus, and navigation - // Matches the native category-switch transition: the incoming page fades in and there is no fade-out crossover. Native - // UpdateScrollState sets each on-page row's mFadeTarget to 1 and each off-page row's to 0, and GUIComponent::Update - // (driven by MenuScreen::Update, which the original runs before this) eases mFadeOpacity toward the target at dt * - // mFadeSpeed - so on-page rows are left entirely to the native ease. Rows are in m_options/g_rows order, so row i is - // on the current page when start <= i < start + rows_per_page. + // Matches the native category-switch transition: the incoming page fades in, with no fade-out crossover. Native + // UpdateScrollState sets each on-page row's mFadeTarget to 1 and off-page rows to 0, and GUIComponent::Update eases + // toward it - so on-page rows are left entirely to the native ease. Rows are in m_options/g_rows order. static void sync_scroll_fade(MiscSettingsScreen* screen) { const std::size_t first = screen->m_page_start_index; @@ -3806,12 +3748,10 @@ namespace big::mod_settings } } - // Focuses the first selectable row so the controller/keyboard cursor lands on it, as a native category does when - // shown. The engine's DoShowCategory teleports the free-form cursor onto mOptions[0] and clears mCategoryFocused - // (switching from tab to option navigation) only when the option list is already populated at that point our rows are - // appended afterwards, so it is skipped - leaving the screen in tab-navigation mode, which is why the stick never - // reaches the rows (no highlight, sliders ignore left/right) until the tab is selected a second time. The row must be - // selectable. + // Focuses the first selectable row so the controller/keyboard cursor lands on it, as a native category does. The + // engine's DoShowCategory only does this when the option list is already populated, and our rows are appended + // afterwards - so without this the screen stays in tab-navigation mode and the stick never reaches the rows until + // the tab is selected a second time. static void focus_row(MiscSettingsScreen* screen, GUIComponent* component) { if (!g_teleport_cursor || (g_use_mouse && *g_use_mouse) || !component) @@ -3839,11 +3779,9 @@ namespace big::mod_settings } } - // After a native page scroll (the on-screen arrow's auto-activate fires MiscSettingsScreen::ScrollDown/ScrollUp), the - // engine selects the new page's edge row directly - mOptions[pageStart] going down, the last on-page row going up - - // via SetMouseOver plus a free-form cursor teleport, without consulting mFreeFormSelectable. If every row on the page - // is disabled it leaves the native edge selection as a fallback. Mouse mode is not touched (the pointer drives hover - // itself). + // After a native page scroll the engine selects the new page's edge row directly, without consulting + // mFreeFormSelectable, so redirect it to the first selectable row instead. Falls back to the native edge selection + // when every row on the page is disabled. Mouse mode is untouched, since the pointer drives hover itself. static void redirect_page_landing(MiscSettingsScreen* screen, bool going_down) { if (!g_set_mouse_over || !g_teleport_cursor || (g_use_mouse && *g_use_mouse) || g_rows.empty()) @@ -3950,11 +3888,9 @@ namespace big::mod_settings return (g_input_get_state(input, control) & 0x4u) != 0; } - // Holds the clicked row (captured before a click-triggered instant rebuild) as the moused-over and selected component, - // and forces our bottom prompt and description to re-apply, for a few frames after the rebuild. The native hover pass - // runs in HandleInput (after this Update) and, over the freshly laid-out rows, can transiently resolve the stationary - // cursor to a neighbouring row or clear the prompt label, so re-asserting here each frame keeps the prompt, - // description and highlight steady on the clicked row instead of blinking onto a neighbour or to a bare glyph. + // Holds the clicked row as moused-over and selected, and re-applies our prompt and description, for a few frames + // after a click-triggered rebuild. The native hover pass runs later in HandleInput and over freshly laid-out rows can + // transiently resolve the stationary cursor to a neighbour, which would blink the highlight and prompt. static void reassert_keep_active_row(MiscSettingsScreen* screen) { if (!(g_use_mouse && *g_use_mouse)) @@ -4000,12 +3936,10 @@ namespace big::mod_settings fn(comp, (static_cast(yb) << 32) | xb); } - // Reverts a stale highlight left on the wrong slider or num-box row. Slider and num-box differ in WHICH handler sets - // the look: a slider's moused-over look (green label + bright fill) is set by OnMouseOver and reverted by OnMouseOff - // (vtbl+0x60). A num-box's lit look (black box + green label) is set by OnSelected and reverted by OnUnselected - // (vtbl+0x88) - its OnMouseOff is an inherited no-op. The num-box OnSelected look fires under keyboard/controller nav - // too (not just mouse), so its revert is gated to mouse mode to avoid clearing a genuine gamepad selection. Both also - // carry a focus look (green value + mFocused) reverted by OnFocusOff (vtbl+0x118). + // Reverts a stale highlight left on the wrong slider or num-box row. The two differ in which handler sets the look: a + // slider's is set by OnMouseOver and reverted by OnMouseOff, a num-box's by OnSelected and reverted by OnUnselected + // (its OnMouseOff is an inherited no-op). The num-box look also fires under keyboard/controller nav, so its revert is + // gated to mouse mode to avoid clearing a genuine gamepad selection. Both carry a focus look reverted by OnFocusOff. static void clear_stale_widget_highlight(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); @@ -4095,12 +4029,10 @@ namespace big::mod_settings } } - // Makes the keyboard/controller spatial nav skip every disabled/greyed row so DOWN/UP jumps straight to the next - // interactable one (with the native wrap and cross-page paging), while leaving mouse hover untouched so a mouse user - // can still rest on a greyed row to read its description. It clears mData.mDef.mFreeFormSelectable on each disabled - // row and the paired value-display column - the only gate SearchInDirection checks before IsSelectable, and one - // MenuScreen::UpdateMouseOver never reads. Called after every build: the row objects are recreated each time, so a - // fresh build restores the default before this reapplies it. + // Makes the spatial nav skip disabled rows so UP/DOWN jumps to the next interactable one, while leaving mouse hover + // alone so a greyed row can still be rested on to read its description. mFreeFormSelectable is the only gate + // SearchInDirection checks before IsSelectable, and one UpdateMouseOver never reads. Reapplied after every build, + // since the row objects are recreated each time. static void apply_row_freeform_selectability() { for (const auto& row : g_rows) @@ -4337,12 +4269,9 @@ namespace big::mod_settings return text.substr(pos + marker.size()); } - // Restores the current mod's config entries to their defaults, but only those whose MENU path lies within the - // current view (the drilled-in group and its subgroups), so a Reset inside a group leaves sibling and parent groups - // untouched. At the mod root (g_view_section == root_section) every described entry is in scope, so the whole mod - // resets. The menu path follows the configDesc grouping (a `group` override else the config section), matching what - // the page shows. The default comes from the config.lua value captured by rom.mod_settings.load when available, and - // otherwise from the config entry's own stored default. + // Restores config entries to their defaults, but only those whose MENU path lies within the current view, so a Reset + // inside a group leaves siblings and parents untouched. At the mod root that is every described entry. Defaults come + // from the config.lua value captured by rom.mod_settings.load when available, else the entry's own stored default. static bool reset_settings_to_defaults() { bool any_changed = false; @@ -4528,11 +4457,9 @@ namespace big::mod_settings } } - // Shows the native single-button message box (sgg::MessageDialog, the same box the game uses in the main menu for - // save/file. Errors), modal over the options screen, with `title` as the heading and `message` as the body. When - // confirm_closes_game is true the confirm button is captured so the OnClicked hook closes the game on press (used for - // a forced restart, which must not be cancellable) otherwise the button keeps its native behaviour and simply - // dismisses the dialog (used for informational prompts). + // Shows the native single-button message box, modal over the options screen. When confirm_closes_game is set the + // confirm button is captured so the OnClicked hook closes the game on press (a forced restart, which must not be + // cancellable); otherwise the button keeps its native dismiss behaviour. static bool show_message_dialog(void* screen_manager, const char* title, const std::string& message, bool confirm_closes_game) { if (screen_manager && g_message_dialog_ctor && g_add_screen) @@ -4802,12 +4729,10 @@ namespace big::mod_settings commit_row_number(row, new_value); } - // Value-change hook for our native slider rows GUIComponentSlider::SetFraction is called with notify=true on every - // user drag/left-right adjust (the native handler also rewrites the value text to a percentage). Fires for the native - // audio sliders too, hence the find_row filter. We deliberately leave mFraction continuous (we do NOT write the - // snapped value back to it): the native adjust accumulates a small per-frame delta into mFraction, so re-snapping it - // each frame would discard any delta smaller than half a step and a partial stick deflection would never move the - // slider. + // Persists a user drag or adjust on our slider rows. Fires for the native audio sliders too, hence the find_row + // filter. mFraction is deliberately left continuous rather than snapped: the native adjust accumulates a small + // per-frame delta into it, so re-snapping each frame would discard any delta below half a step and a partial stick + // deflection would never move the slider. static void hook_GUIComponentSlider_SetFraction(void* self, float fraction, bool notify) { big::g_hooking->get_original()(self, fraction, notify); @@ -4885,12 +4810,9 @@ namespace big::mod_settings } // Discrete keyboard/controller stepping for our slider rows, and a disabled-row guard. The native - // GUIComponentSlider::HandleInput slides mFraction continuously (axisSum * speed * dt behind a 0.5 dead-zone, summing - // dpad, arrow keys, WASD and the left stick), so a small tap can land back on the same snapped value. For our rows - // under keyboard/controller (UseMouse off) we bypass that path and move exactly one step on each left/right press - // edge, so every input changes the value by at least one step and a held direction cannot creep between steps, gated - // on the slider's own mFocused@0x548 (the native slide gate) so only the entered slider - not every visible one - - // reacts. + // The native HandleInput slides mFraction continuously behind a dead-zone, so a small tap can land back on the same + // snapped value. Under keyboard/controller we bypass it and move exactly one step per left/right press edge, gated + // on the slider's own mFocused so only the entered slider reacts. static bool hook_GUIComponentSlider_HandleInput(void* self, void* input, float dt) { PanelRow* row = self ? find_row(reinterpret_cast(self)) : nullptr; @@ -5072,12 +4994,10 @@ namespace big::mod_settings return result; } - // Restores vertical breathing room around action-button rows (Apply/Reset), whose taller Button_Secondary box would - // otherwise crowd the neighbouring setting rows on the uniform grid. Run right after the native UpdateScrollState has - // laid every on-page row on the grid: within the current page we nudge each action button down by button_extra_lead - // and shift the rows below it by lead+trail (accumulated). The shift is applied through the engine's own SetLocation - // so each row's child components follow (a raw m_location_y write leaves them behind, which is what drove the earlier - // slider-bar drift). + // Restores vertical breathing room around action-button rows, whose taller Button_Secondary box would otherwise + // crowd neighbouring setting rows on the uniform grid. Runs after the native UpdateScrollState has laid out the + // page. The shift goes through the engine's own SetLocation so each row's child components follow - a raw + // m_location_y write leaves them behind, which is what caused the earlier slider-bar drift. static void apply_button_spacing(MiscSettingsScreen* screen) { const std::size_t first = screen->m_page_start_index; @@ -5180,14 +5100,11 @@ namespace big::mod_settings } } - // A slider drag, number-box adjust, or freetext commit in a view that has dynamic (function) rows re-evaluates those - // rows (e.g. an apply button enabling itself when a value changes). The rebuild frees and recreates the rows, so it - // is deferred two ways: a short debounce absorbs the per-frame slider hook, and while the user is still actively - // adjusting a row (with keyboard/controller, or an in-progress mouse drag) the rebuild is HELD until they finish - - // otherwise it would free the focused slider mid-adjust or interrupt a mouse drag. The debounce also coalesces a - // burst of edits into a single rebuild: each commit re-arms the timer, only the final expiry rebuilds, build_panel - // clears the timer so its own rebuild cancels any still-pending one, and the !g_nav_pending guard folds this into a - // rebuild already queued by an instant path (a toggle / action). + // An edit in a view with dynamic (function) rows re-evaluates them, which frees and recreates every row. That is + // deferred twice over: a debounce absorbs the per-frame slider hook and coalesces bursts (each commit re-arms + // it), and the rebuild is HELD while the user is still adjusting a row, since otherwise it would free the + // focused slider mid-adjust or interrupt a drag. build_panel clears the timer so its own rebuild cancels any + // pending one, and the !g_nav_pending guard folds this into a rebuild already queued by an instant path. if (g_dynamic_refresh_settle > 0.0f) { if (!on_mods_tab) @@ -5289,11 +5206,10 @@ namespace big::mod_settings return result; } - // While a freetext setting is being edited, read Enter (confirm) and Escape (cancel) from the game's own per-frame - // input, commit/cancel here, then swallow the screen's input handling entirely so menu navigation and the - // Escape-to-close do not react. Committing here (rather than in Update) is important: HandleInput returns true this - // frame, so a submitting mouse click is swallowed and cannot also activate the row it lands on. ExitScreen back-nav, - // so we detect it here (before the original) and run the back-nav ourselves. + // While a freetext setting is being edited, read Enter and Escape from the game's own per-frame input, commit or + // cancel here, then swallow the screen's input handling so menu navigation and Escape-to-close do not react. + // Committing here rather than in Update matters: returning true this frame also swallows a submitting mouse click, + // so it cannot activate the row it lands on. static bool hook_MiscSettingsScreen_HandleInput(void* self, void* input, float x) { if (g_editing) @@ -5356,11 +5272,9 @@ namespace big::mod_settings return result; } - // Close funnel for the options screen: every way the user dismisses it (Escape key, controller B, or clicking the - // on-screen "Exit" button) converges here (MiscSettingsScreen::ExitScreen, vtable slot 7), before any fade/teardown - // and while mScreenManager is valid. If a restart is required, show the native message box and DO NOT run the original - // (veto the close): The box is modal over the still-open options screen and its button closes the game. A - // restart-required change must not be cancellable (that would require undoing the change), so the restart is forced. + // Close funnel: every way the user dismisses the screen converges here, before any fade/teardown and while + // mScreenManager is valid. If a restart is required, show the message box and veto the close - the box is modal over + // the still-open screen and its button closes the game, since a restart-required change cannot be cancelled. static void hook_MiscSettingsScreen_ExitScreen(void* self) { // Inside a mod's settings, Esc/controller B/the on-screen Back button steps up one level: a nested group @@ -5517,12 +5431,10 @@ namespace big::mod_settings missing.push_back("ucrtbase.dll _aligned_malloc/_aligned_free (the game's CRT heap)"); } - // The num-box factory (a template instantiation) and the restart-dialog ctor and AddScreen overloads cannot be picked - // by name from the PDB, so they are addressed by hardcoded RVA off the button-ctor anchor. Those RVAs and every - // struct offset this feature uses are valid only for the Ship build they were captured against, and unlike the - // name-resolved symbols above they do NOT auto-adapt, so a game update can move them and hang/crash the options - // screen. Gate the whole menu on the exact build via its PDB GUID: after an update the GUID no longer matches and the - // menu is cleanly skipped (the rom.mod_settings Lua API is unaffected) until Hell2Modding is updated. + // The hardcoded RVAs and struct offsets above are valid only for the Ship build they were captured against, and + // unlike the name-resolved symbols they do NOT auto-adapt - a game update could move them and crash the options + // screen. So gate the menu on the exact build via its PDB GUID: after an update the GUID no longer matches and + // the tab is cleanly skipped (the rom.mod_settings Lua API is unaffected) until Hell2Modding is updated. static constexpr const char* validated_pdb_guid = "744ea71c-2c21-4b40-a6c486d1fa6647da"; const bool build_validated = big::hades2_pdb_guid == validated_pdb_guid; diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index fc495da..7e5feed 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -17,14 +17,10 @@ namespace big::mod_settings using localized_text = std::map; // When a setting may be changed, relative to whether a save is loaded. The Lua state is recreated when a save is - // loaded from the main menu, so init-time changes (GameData edits, function patches) only take effect if made - // before that point, while some settings only apply to a live run. The settings menu greys a row (read-only, with a - // note) when the current context does not match: any: editable anywhere (default live-read settings). main_menu: - // only from the main menu (greyed while a save is loaded). Forced for a mod's master "enabled" toggle and for any - // restartRequired setting. in_save: only while a save is loaded, both in the hub and mid-run (greyed at the main - // menu). in_hub: only while in the hub/Crossroads (greyed at the main menu AND mid-run), for settings unsafe to - // change during a run. Authors declare this per setting via - // `editableContext = "mainMenu" | "inSave" | "inHub" | "any"`. + // loaded, so init-time changes (GameData edits, function patches) only take effect if made before that point, while + // some settings only apply to a live run. A row whose context does not match is greyed with a note. + // any: anywhere. main_menu: forced for the master "enabled" toggle and any restartRequired setting. + // in_save: hub and mid-run. in_hub: the Crossroads only, for settings unsafe to change during a run. enum class editable_context { any, @@ -46,11 +42,9 @@ namespace big::mod_settings enumeration, }; - // An author-declared menu group (configDesc `groups`): a category in the in-game menu that does NOT correspond to a - // config section. It lets a mod present a flat (or differently nested) config under an arbitrary menu tree, by - // moving entries into these groups with a per-entry `group`. `id` is the identity used in a `group` path (the table - // key in configDesc.groups). name/description are shown in the menu (resolved to the current language). order sorts - // it among its siblings (else first-declared order). children are nested sub-groups. + // An author-declared menu category (configDesc `groups`) that does NOT correspond to a config section, letting a mod + // present a flat or differently-nested config under an arbitrary menu tree via a per-entry `group`. `id` is the + // identity a `group` path references (the table key in configDesc.groups). struct menu_group { std::string id; @@ -66,11 +60,9 @@ namespace big::mod_settings // do not exist as config sections. Populated fresh each Lua-state init by rom.mod_settings.load. std::vector mod_menu_groups(const std::string& guid); - // Author-declared metadata for a single setting, extracted from its config.lua description table by - // rom.mod_settings.load. Consulted by the settings menu. Only settings whose description is a rich table have an - // entry. The rest fall back to type-based rendering. Every field is an author-only input that cannot be inferred - // from the config value. The widget kind itself is inferred from the value and `values`. All fields are optional - // (see the has_* flags). + // Author-declared metadata for one setting, extracted from its config.lua description table. Only settings whose + // description is a rich table have an entry; the rest fall back to type-based rendering. Every field is an + // author-only input that cannot be inferred from the config value, and all are optional (see the has_* flags). struct setting_metadata { localized_text name; // display-name override (empty -> prettified key) @@ -153,11 +145,9 @@ namespace big::mod_settings // Returns false when the Lua state is unavailable. bool game_is_in_hub(); - // Like get_setting_metadata, but re-evaluates the setting's dynamic (Lua-function) description fields against the - // current game state, returning up-to-date values (slider bounds, enum options, hidden, display name, ...). Call - // this (on the game thread, while the Lua state is alive) when get_setting_metadata reports has_dynamic. The - // returned metadata never has has_dynamic set. Returns std::nullopt for settings with no stored description (e.g. - // Chalk-bound). + // Like get_setting_metadata, but re-evaluates the setting's dynamic (Lua-function) fields against the current game + // state. Call on the game thread, while the Lua state is alive, when get_setting_metadata reports has_dynamic. The + // returned metadata never has has_dynamic set. nullopt for settings with no stored description (e.g. Chalk-bound). std::optional resolve_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); // A menu button declared in config.lua that runs a Lua callback instead of editing a config value: an `action = @@ -186,11 +176,9 @@ namespace big::mod_settings // does not resolve to an action. Call on the game thread while the Lua state is alive. void invoke_action(const std::string& guid, const std::string& section, const std::string& key); - // A configDesc entry with NO backing config value that explicitly marks itself `virtual = true`. It renders as a - // menu row whose value comes from Lua callbacks instead of a .cfg config entry: a read-only row uses `text`, and an - // interactive row uses `get` (read) + `set` (write). Collected at load. The callables stay in the Lua descs - // registry and are resolved at render. The rest of its metadata (displayName/description/order/min/max/values/...) - // is read the same way as a config setting's, via resolve_setting_metadata against (section, key). + // A configDesc entry with NO backing config value, marked `virtual = true`. Its value comes from Lua callbacks: a + // read-only row uses `text`, an interactive row `get`/`set`. The callables stay in the Lua descs registry and are + // resolved at render; the rest of its metadata is read like a config setting's, via resolve_setting_metadata. struct virtual_row_info { std::string section; diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp index 69fbc49..c35145f 100644 --- a/src/hades2/mod_settings/sgg_gui.hpp +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -3,16 +3,13 @@ #include #include -// Minimal views over the native Hades II option-screen GUI objects, limited to the fields this feature reads or writes. -// Offsets are validated with static_assert against the current game build. The matching engine functions are resolved -// by PDB symbol name at runtime (see big::hades2_symbol_to_address). Only sgg::GUIComponent base fields and -// MiscSettingsScreen members are used, which stay stable across the button-layout changes that occur between game -// versions. +// Minimal views over the native option-screen GUI objects, limited to the fields this feature reads or writes. Only +// sgg::GUIComponent base fields and MiscSettingsScreen members are used, which stay stable across the button-layout +// changes that occur between game versions. namespace big::mod_settings::sgg { - // sgg::Vectormath Vector2: two floats, 8 bytes. As a function argument this is an integer-class aggregate, so it is - // passed in a general-purpose register (RDX/R8/...), not an XMM register - the by-value POD typing below reproduces - // that ABI. + // Two floats, 8 bytes. As a function argument this is an integer-class aggregate, so it is passed in a + // general-purpose register rather than an XMM one - the by-value POD typing reproduces that ABI. struct Vec2 { float x; @@ -88,27 +85,22 @@ namespace big::mod_settings::sgg inline constexpr std::size_t gui_component_button_owner_offset = 0x5'A0; inline constexpr std::size_t gui_component_button_size = 0x5'B0; - // Byte offset of GUIComponentButton::mSelectable (bool). GUIComponentButton::IsSelectable returns it. - // MenuScreen::SetMouseOver skips a component whose IsSelectable is false, so clearing it makes a button - // non-hoverable and non-selectable (used to fully disable a greyed action button). + // IsSelectable returns this, and MenuScreen::SetMouseOver skips a component whose IsSelectable is false - so + // clearing it makes a button non-hoverable and non-selectable. inline constexpr std::size_t gui_component_button_selectable_offset = 0x5'51; - // Byte offset of GUIComponentButton::mUnderMouseTexture (sgg::TextureHandle, a 32-bit id). GUIComponentButton::Draw - // draws this hover-highlight overlay only when it is valid and mIsUseable@0x27 is set (gate at Draw+0xBF). A - // greyed-but-hoverable action (kept useable so it can show its description) clears this so it does not flash a - // clickable-looking hover glow. mSelectedTexture (selection overlay) is at 0x564 (SetSelectedTexture clears it). + // mUnderMouseTexture: Draw paints this hover-highlight overlay only when it is valid and mIsUseable is set. A greyed + // but still hoverable action clears it so it does not flash a clickable-looking glow. The selection overlay + // mSelectedTexture is at 0x564, cleared via SetSelectedTexture. inline constexpr std::size_t gui_component_button_under_mouse_texture_offset = 0x5'68; - // Byte offset of GUIComponentButton::mDisplayNameId (sgg::HashGuid: a 32-bit interned-string id). The engine - // derives a button's visible label from this id:. GUIComponentButton::UseDefaultText resolves the id back to its - // interned string, looks that up in the localized text data, and sets the label from the result (falling back to - // the raw string on a miss). UseDefaultText re-runs on every localization pass, including a live language change, - // so this id - not any string handed to SetDisplayName - is what determines the persistent label. + // mDisplayNameId, a 32-bit interned-string id. UseDefaultText resolves it back to its interned string, looks that up + // in the localized text data and sets the label from the result. It re-runs on every localization pass, including a + // live language change, so this id - not any string handed to SetDisplayName - determines the persistent label. inline constexpr std::size_t gui_component_button_display_name_id_offset = 0x1'68; - // sgg::MenuScreen, the base of MiscSettingsScreen mComponents owns every live widget that is drawn and hit-tested - // freed components are dropped from it mAnchor is the base location the engine gives freshly created option - // components. + // mComponents owns every live widget that is drawn and hit-tested; freed components are dropped from it. mAnchor is + // the base location the engine gives freshly created option components. struct MenuScreen { char m_pad_anchor[0x50]; @@ -129,9 +121,8 @@ namespace big::mod_settings::sgg static_assert(offsetof(MenuScreen, m_cancel_button) == 0x1'A8); static_assert(offsetof(MenuScreen, m_selected_component) == 0x1'B0); - // sgg::MiscSettingsScreen, the native tabbed options screen. The category buttons are laid out contiguously from - // +0x388 (Gameplay) to +0x3F8 (Debug). The non-user categories such as Editor follow the eight user-facing ones - // mOptions holds the current category's option components. + // The native tabbed options screen. Category buttons are laid out contiguously from +0x388 (Gameplay) to +0x3F8 + // (Debug), the non-user categories such as Editor following the eight user-facing ones. struct MiscSettingsScreen { char m_pad_psi[0x3'44]; From ca18282da6250e192956d18dd10061610dfa8c02 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:11:37 +0100 Subject: [PATCH 076/100] clang-format --- src/hades2/mod_settings/config_api.cpp | 53 +++---- src/hades2/mod_settings/mod_settings.cpp | 171 ++++++++++++----------- 2 files changed, 114 insertions(+), 110 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 319a1e2..3a13684 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -28,7 +28,7 @@ using namespace al; namespace big::mod_settings { - #pragma region Metadata registries and accessors +#pragma region Metadata registries and accessors // Author-declared per-setting metadata (display name, bounds, enum options, ordering, restart flag), populated from // each mod's config.lua by rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Only settings with a @@ -171,9 +171,9 @@ namespace big::mod_settings return it != g_menu_groups.end() ? it->second : std::vector{}; } - #pragma endregion +#pragma endregion - #pragma region Source-order ranking helpers +#pragma region Source-order ranking helpers // Byte offset of a key's definition (" =") in config.lua source at or after `start` (whole-word, not "=="), // or npos. Occurrences inside strings/prose do not match because they are not followed by a bare '='. @@ -222,9 +222,9 @@ namespace big::mod_settings return second != std::string::npos ? second : first; } - #pragma endregion +#pragma endregion - #pragma region Config.lua parsing helpers +#pragma region Config.lua parsing helpers static std::string serialize_option(const sol::object& v); // defined below. @@ -353,7 +353,7 @@ namespace big::mod_settings } sol::table gt = v.as(); menu_group g; - g.id = k.as(); + g.id = k.as(); if (g.id.find('.') != std::string::npos) { LOG(WARNING) << "[mod_settings] ignoring menu group id '" << g.id << "' containing '.', which is reserved as the menu-path separator (nest via a `groups` sub-table instead)."; @@ -439,9 +439,9 @@ namespace big::mod_settings } } - #pragma endregion +#pragma endregion - #pragma region Metadata extraction +#pragma region Metadata extraction // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. Captures the // author-only inputs that can't be inferred (name, bounds, enum options/labels, order, hidden, restart). The widget @@ -589,9 +589,9 @@ namespace big::mod_settings return m; } - #pragma endregion +#pragma endregion - #pragma region Description navigation and dynamic-field resolution +#pragma region Description navigation and dynamic-field resolution // The Lua-side registry (rom.mod_settings._descs) mapping guid -> the mod's raw configDesc table, kept alive so // dynamic description fields and action callbacks can be evaluated at render. Recreated each Lua state, so it never @@ -665,7 +665,7 @@ namespace big::mod_settings // Invokes a mod-supplied Lua callback protected, with the silent handler above rather than ReturnOfModding's // default: callers report failures themselves with one concise WARNING, so a callback that legitimately fails in // some contexts (e.g. reading run state from the main menu) does not also spam an ERROR plus full traceback. - template + template static sol::protected_function_result call_mod_callback(sol::protected_function fn, Args&&... args) { const lua_CFunction handler = &silent_error_handler; @@ -716,9 +716,9 @@ namespace big::mod_settings return out; } - #pragma endregion +#pragma endregion - #pragma region Action and virtual-row collection +#pragma region Action and virtual-row collection // Reads the static (non-function) action metadata common to collection and dynamic re-resolution. static void read_action_fields(const sol::table& entry, action_info& a) @@ -948,9 +948,9 @@ namespace big::mod_settings } } - #pragma endregion +#pragma endregion - #pragma region Config entry access and change hooks +#pragma region Config entry access and change hooks // Finds the config entry for (section, key), or nullptr. m_entries is keyed by config_definition, so this is a // direct map lookup. @@ -1055,9 +1055,9 @@ namespace big::mod_settings return value > 0; } - #pragma endregion +#pragma endregion - #pragma region Config proxy +#pragma region Config proxy // Registry keys for the config proxy: one shared metatable, plus two weak-keyed maps from each wrapper table to the // config_file and section it points at, so the metamethods can recover them per call. @@ -1303,9 +1303,9 @@ namespace big::mod_settings return recover(ts, self).inext(ts, index); } - #pragma endregion +#pragma endregion - #pragma region Default binding and config.lua load +#pragma region Default binding and config.lua load // A setting's extracted metadata together with the section/key it belongs to, collected while walking config.lua // and then folded into the registry. @@ -1591,9 +1591,9 @@ namespace big::mod_settings return make_proxy(ts, cf.get(), "config"); } - #pragma endregion +#pragma endregion - #pragma region Dynamic metadata and game-state accessors +#pragma region Dynamic metadata and game-state accessors std::optional resolve_setting_metadata(const std::string& guid, const std::string& section, const std::string& key) { @@ -1834,9 +1834,9 @@ namespace big::mod_settings } } - #pragma endregion +#pragma endregion - #pragma region Virtual-row value helpers and reset +#pragma region Virtual-row value helpers and reset // Parses a serialized scalar (as produced by serialize_option) back to a double, or 0.0 if it is not numeric. static double parse_serialized_number(const std::string& s) @@ -1958,9 +1958,9 @@ namespace big::mod_settings return true; } - #pragma endregion +#pragma endregion - #pragma region Opt-out and API registration +#pragma region Opt-out and API registration // Lua API: Function. Table: mod_settings. Name: opt_out. Param: description: string: Optional. A plain string or a // localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note. Excludes the calling @@ -2038,6 +2038,7 @@ namespace big::mod_settings // C++ statics (which would dangle across a Lua-state reset). ns["_descs"] = state.create_table(); } - #pragma endregion + +#pragma endregion } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 23217a4..f6c9f84 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -28,7 +28,7 @@ using namespace al; namespace big::mod_settings { - #pragma region Native screen offsets, RVAs, and constants +#pragma region Native screen offsets, RVAs, and constants using sgg::GUIComponent; using sgg::MenuScreen; @@ -191,31 +191,31 @@ namespace big::mod_settings static constexpr std::size_t vtable_on_mouse_off_offset = 0x00'60; // GUIComponent::OnMouseOff slot static constexpr std::size_t vtable_on_unselected_offset = 0x00'88; // GUIComponent::OnUnselected slot static constexpr std::size_t vtable_on_focus_off_offset = 0x1'18; // GUIComponent::OnFocusOff slot - static constexpr std::size_t vtable_set_location_offset = 0x1'80; // GUIComponent::SetLocation slot (moves the component and its children) + static constexpr std::size_t vtable_set_location_offset = 0x1'80; // GUIComponent::SetLocation slot (moves the component and its children) // Greying a slider/num-box: the button-style def greying does not reach their separate label/value text boxes or // bar/arrow graphics, so each is greyed directly. A text box renders mDisabledText only when its def carries a // non-negative disabled colour, so that is written explicitly; the value box is flagged too, since Draw never // touches it. An image tints from mColor every frame, so mColorTarget is written as well or a lerp undoes it. - static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor + static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor // Greying the normal and selected colours too keeps the label grey in every state, matching set_def_text_grey. - static constexpr std::size_t textbox_text_red = 0x1'B4; // mData.mDef.mTextRed (float) - static constexpr std::size_t textbox_selected_text_red = 0x1'D0; // mData.mDef.mSelectedTextRed (float) - static constexpr std::size_t textbox_disabled_text_red = 0x1'E8; // mData.mDef.mDisabledTextRed (float) - static constexpr std::size_t textbox_disabled_text_green = 0x1'EC; // mDisabledTextGreen (float) - static constexpr std::size_t textbox_disabled_text_blue = 0x1'F0; // mDisabledTextBlue (float) - static constexpr std::size_t textbox_disabled_text_alpha = 0x1'F4; // mDisabledTextAlpha (float) - static constexpr std::size_t image_color_offset = 0x5'44; // GUIComponentImage::mColor (packed RGBA) - static constexpr std::size_t image_color_target_offset = 0x00'78; // mColorTarget (packed RGBA) - static constexpr std::size_t button_graphic_color_offset = 0x5'5C; // GUIComponentButton::mButtonColor - the colour Draw paints the toggle graphic with - static constexpr std::size_t component_color_target_offset = 0x00'78; // GUIComponent::mColorTarget (Update eases mButtonColor toward this) - static constexpr std::size_t def_sel_red = 0xFC; // ComponentDataDef::mSelectedRed - set <0 to disable the selected-colour override in Draw/On(Un)Selected - static constexpr float disabled_text_grey = 0.22f; // matches set_def_text_grey (toggle/text rows) - static constexpr std::uint32_t disabled_graphic_grey = 0xFF'66'66'66; // opaque 0.4 grey (packed A,B,G,R) + static constexpr std::size_t textbox_text_red = 0x1'B4; // mData.mDef.mTextRed (float) + static constexpr std::size_t textbox_selected_text_red = 0x1'D0; // mData.mDef.mSelectedTextRed (float) + static constexpr std::size_t textbox_disabled_text_red = 0x1'E8; // mData.mDef.mDisabledTextRed (float) + static constexpr std::size_t textbox_disabled_text_green = 0x1'EC; // mDisabledTextGreen (float) + static constexpr std::size_t textbox_disabled_text_blue = 0x1'F0; // mDisabledTextBlue (float) + static constexpr std::size_t textbox_disabled_text_alpha = 0x1'F4; // mDisabledTextAlpha (float) + static constexpr std::size_t image_color_offset = 0x5'44; // GUIComponentImage::mColor (packed RGBA) + static constexpr std::size_t image_color_target_offset = 0x00'78; // mColorTarget (packed RGBA) + static constexpr std::size_t button_graphic_color_offset = 0x5'5C; // GUIComponentButton::mButtonColor - the colour Draw paints the toggle graphic with + static constexpr std::size_t component_color_target_offset = 0x00'78; // GUIComponent::mColorTarget (Update eases mButtonColor toward this) + static constexpr std::size_t def_sel_red = 0xFC; // ComponentDataDef::mSelectedRed - set <0 to disable the selected-colour override in Draw/On(Un)Selected + static constexpr float disabled_text_grey = 0.22f; // matches set_def_text_grey (toggle/text rows) + static constexpr std::uint32_t disabled_graphic_grey = 0xFF'66'66'66; // opaque 0.4 grey (packed A,B,G,R) // The template caches a bright colour at build time and greying the def alone does not update it, so a still- // selectable greyed label stays bright - SetTextColor re-applies the grey, as UpdateButtonStates does. - static constexpr std::size_t vtable_set_text_color_offset = 0x1'60; - static constexpr std::uint32_t disabled_label_grey_packed = 0xFF'38'38'38; + static constexpr std::size_t vtable_set_text_color_offset = 0x1'60; + static constexpr std::uint32_t disabled_label_grey_packed = 0xFF'38'38'38; // NumBox::OnSelected turns the box black by writing the selected colour into the animation's own mColor. static constexpr std::size_t animation_color_offset = 0x5'58; // GUIComponentAnimation::mColor (packed ARGB) @@ -257,9 +257,9 @@ namespace big::mod_settings using mouse_button_down_fn = bool (*)(void* input_handler); using input_dir_pressed_fn = bool (*)(void* input_handler); - #pragma endregion +#pragma endregion - #pragma region Native bindings, panel model, and menu state +#pragma region Native bindings, panel model, and menu state // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid @@ -303,22 +303,22 @@ namespace big::mod_settings // A patched copy of the slider vtable (built in set_up_hooks) whose GetArea/GetScreenArea slots return a one-row // hit rect (see row_bounded_area), replacing the native ones that union the slider's sub-components into a // screen-spanning rect. 128 slots comfortably covers the class's virtual table. - static constexpr std::size_t slider_vtable_slot_count = 128; + static constexpr std::size_t slider_vtable_slot_count = 128; // The highest slot we override or copy through is SetLocation at +0x180, so keep the buffer big enough for it. - static_assert(0x180 / sizeof(std::uintptr_t) < slider_vtable_slot_count, "vtable copy buffer too small for the highest patched slot"); - static std::uintptr_t g_slider_vtable_copy[slider_vtable_slot_count] = {}; - static std::uintptr_t g_slider_vtable_patched = 0; // runtime + static_assert(0x1'80 / sizeof(std::uintptr_t) < slider_vtable_slot_count, "vtable copy buffer too small for the highest patched slot"); + static std::uintptr_t g_slider_vtable_copy[slider_vtable_slot_count] = {}; + static std::uintptr_t g_slider_vtable_patched = 0; // runtime // A patched copy of the GUIComponentButton vtable (built lazily in install_wide_button_nav_rect from the first action // button's vtable) whose GetArea/GetScreenArea slots return the same wide one-row rect (row_bounded_area), so a // centre-column action button is reachable by the vertical spatial nav. Every other button row keeps the native // vtable. static std::uintptr_t g_button_vtable_copy[slider_vtable_slot_count] = {}; - static std::uintptr_t g_button_vtable_patched = 0; // runtime - static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) - static set_mouse_over_fn g_set_mouse_over = nullptr; // MenuScreen::SetMouseOver (highlight + select a row) - static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) - static const char* g_config_language = nullptr; // sgg::ConfigOptions::Language + static std::uintptr_t g_button_vtable_patched = 0; // runtime + static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) + static set_mouse_over_fn g_set_mouse_over = nullptr; // MenuScreen::SetMouseOver (highlight + select a row) + static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) + static const char* g_config_language = nullptr; // sgg::ConfigOptions::Language static component_focused_fn g_component_focused = nullptr; // focuses a row so it receives stick input + green static input_get_state_fn g_input_get_state = nullptr; // reads a remappable control's per-frame state @@ -554,9 +554,9 @@ namespace big::mod_settings // Journey" "zerp-DreamDiveTweaks" -> "Dream Dive Tweaks". static std::string key_to_display(const std::string& key); // shared friendly-name logic, defined below - #pragma endregion +#pragma endregion - #pragma region Mod identity, text, and Mods-tab helpers +#pragma region Mod identity, text, and Mods-tab helpers static std::string display_name_from_stem(const std::string& stem) { @@ -808,9 +808,9 @@ namespace big::mod_settings } } - #pragma endregion +#pragma endregion - #pragma region Native row construction and styling +#pragma region Native row construction and styling // Writes an in-place EASTL short-string (SSO, up to 22 chars) into a component field. static void set_sso_string(void* field, const char* text) @@ -973,7 +973,7 @@ namespace big::mod_settings // mSelectedRed < 0 to skip the selected-colour override keeps it greyed at rest and through hover. static void grey_toggle_graphic(GUIComponent* row) { - char* b = reinterpret_cast(row); + char* b = reinterpret_cast(row); *reinterpret_cast(b + button_graphic_color_offset) = disabled_graphic_grey; *reinterpret_cast(b + component_color_target_offset) = disabled_graphic_grey; *reinterpret_cast(b + component_def_offset + def_sel_red) = -1.0f; @@ -1133,6 +1133,7 @@ namespace big::mod_settings // it keeps its Button_Secondary box graphic and centered label - visually distinct from the plain-text setting rows. // Disabled rows are greyed by default they are also hard-disabled (non-selectable). static void install_wide_button_nav_rect(GUIComponent* row); // defined below (near row_bounded_area) + static GUIComponent* make_button_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) { auto* row = create_button(screen); @@ -1500,7 +1501,7 @@ namespace big::mod_settings if (!g_button_vtable_patched) { const std::uintptr_t native_vtable = *reinterpret_cast(row); - g_button_vtable_patched = build_row_area_vtable(g_button_vtable_copy, sizeof(g_button_vtable_copy), native_vtable); + g_button_vtable_patched = build_row_area_vtable(g_button_vtable_copy, sizeof(g_button_vtable_copy), native_vtable); } *reinterpret_cast(row) = g_button_vtable_patched; } @@ -1613,9 +1614,9 @@ namespace big::mod_settings return reinterpret_cast(s); } - #pragma endregion +#pragma endregion - #pragma region Row teardown and mod list +#pragma region Row teardown and mod list // Removes the first pointer equal to `value` from an eastl vector by shifting the tail down in place - the same // unlink the engine's DoShowCategory performs. No-op if not present. The backing storage is left owned by the @@ -1750,9 +1751,9 @@ namespace big::mod_settings } } - #pragma endregion +#pragma endregion - #pragma region Value formatting, freetext editing, and commit +#pragma region Value formatting, freetext editing, and commit // Turns an identifier into a friendly display string: underscores become spaces, and camelCase/PascalCase word // boundaries are split ("z_ThisConfigKey" -> "z. The first letter is capitalized ("enabled" -> "Enabled"). @@ -2364,9 +2365,9 @@ namespace big::mod_settings } } - #pragma endregion +#pragma endregion - #pragma region Editability context and menu-path helpers +#pragma region Editability context and menu-path helpers // True if `key` is the mod's master enable switch ("enabled", any case). static bool is_enabled_key(const std::string& key) @@ -2523,9 +2524,9 @@ namespace big::mod_settings { return nullptr; } - std::string rest = menu_path.substr(prefix.size()); + std::string rest = menu_path.substr(prefix.size()); const std::vector* level = &tree; - const menu_group* found = nullptr; + const menu_group* found = nullptr; while (!rest.empty()) { const auto dot = rest.find('.'); @@ -2589,9 +2590,9 @@ namespace big::mod_settings return p == scope || p.rfind(scope + ".", 0) == 0; } - #pragma endregion +#pragma endregion - #pragma region Panel builder +#pragma region Panel builder // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render as // setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). @@ -2603,10 +2604,10 @@ namespace big::mod_settings std::string key; // leaf key, or the group's last path segment toml_v2::config_file::config_entry_base* entry = nullptr; // leaf only std::string child_section; // group only (full menu path, e.g. "config.x.y") - std::string config_section; // the entry's REAL config section (for virtual I/O - group: its parent config section) - bool is_author_group = false; // group only: declared in configDesc `groups` (not a config section) - localized_text author_name; // author-group display name (is_author_group only) - localized_text author_description; // author-group description (is_author_group only) + std::string config_section; // the entry's REAL config section (for virtual I/O - group: its parent config section) + bool is_author_group = false; // group only: declared in configDesc `groups` (not a config section) + localized_text author_name; // author-group display name (is_author_group only) + localized_text author_description; // author-group description (is_author_group only) bool has_order = false; double order = 0.0; int appearance = INT_MAX; // config.lua source rank (fallback order) @@ -2726,14 +2727,15 @@ namespace big::mod_settings // Undescribed keys are a mod's internal bookkeeping, so they stay off the page. The master "enabled" // toggle is the exception, always shown so the mod stays toggleable even if undescribed. const bool is_enabled_toggle = key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key); - if (!is_enabled_toggle && !setting_is_described(stem, key.m_section, key.m_key) && !entry_has_description(entry.get())) + if (!is_enabled_toggle && !setting_is_described(stem, key.m_section, key.m_key) + && !entry_has_description(entry.get())) { continue; } // `group` is a static field, so the cheap (no-Lua) stored metadata is enough to place the entry. // Everything else still uses its real config section. - const auto static_meta = get_setting_metadata(stem, key.m_section, key.m_key); + const auto static_meta = get_setting_metadata(stem, key.m_section, key.m_key); const std::vector grp = static_meta ? static_meta->group : std::vector{}; std::string child_path; const int place = placement(key.m_section, grp, child_path); @@ -2943,10 +2945,10 @@ namespace big::mod_settings // A `group` override can move a virtual row onto a page whose path differs from its config section, so // all its Lua I/O (metadata/display/get) uses the row's real config section, not the view path. const std::string& vsection = it.config_section; - const auto vmeta = resolved_metadata(stem, vsection, it.key); - const std::string vname = vmeta ? resolve_localized(vmeta->name) : std::string{}; - const std::string vlabel = escape_markup(!vname.empty() ? vname : key_to_display(it.key)); - const std::string vdesc = vmeta ? resolve_localized(vmeta->description) : std::string{}; + const auto vmeta = resolved_metadata(stem, vsection, it.key); + const std::string vname = vmeta ? resolve_localized(vmeta->name) : std::string{}; + const std::string vlabel = escape_markup(!vname.empty() ? vname : key_to_display(it.key)); + const std::string vdesc = vmeta ? resolve_localized(vmeta->description) : std::string{}; // A read-only virtual row, or an interactive row with no widget, becomes key + value text. mIsUseable // stays on so the mouse can still resolve it for the description. @@ -2955,7 +2957,7 @@ namespace big::mod_settings if (auto* row = make_text_row(screen, vlabel.c_str(), /*disabled*/ false, /*block_input*/ false, /*no_hover_highlight*/ true)) { PanelRow pr{row, RowKind::info, stem, it.key}; - pr.disabled = true; + pr.disabled = true; pr.config_section = vsection; pr.value_component = make_value_display(screen, escape_markup(value_text).c_str(), /*disabled*/ false); pr.description = vdesc; @@ -3076,7 +3078,7 @@ namespace big::mod_settings if (auto* ro_row = make_text_row(screen, vlabel.c_str(), /*disabled*/ true, /*block_input*/ false)) { PanelRow pr{ro_row, RowKind::setting, stem, it.key}; - pr.disabled = true; + pr.disabled = true; pr.config_section = vsection; pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); pr.description = @@ -3132,9 +3134,9 @@ namespace big::mod_settings PanelRow pr{row, RowKind::setting, stem, it.key}; pr.disabled = disabled; pr.is_virtual_input = true; - pr.config_section = vsection; // real config section for runtime get/set (may differ from view path) - pr.value_component = value; - pr.description = vdesc; + pr.config_section = vsection; // real config section for runtime get/set (may differ from view path) + pr.value_component = value; + pr.description = vdesc; if (is_enum) { pr.is_enum = true; @@ -3289,12 +3291,12 @@ namespace big::mod_settings GUIComponent* ro_row = nullptr; GUIComponent* ro_value = nullptr; bool ro_is_toggle = false; - bool ro_is_enum = false; // real enum cycler (carries values/labels) - bool ro_is_numbox = false; // numeric num-box (stepper fallback when the slider cannot be built) + bool ro_is_enum = false; // real enum cycler (carries values/labels) + bool ro_is_numbox = false; // numeric num-box (stepper fallback when the slider cannot be built) bool ro_is_slider = false; if (entry->type() == typeid(bool)) { - ro_row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), /*disabled*/ true, /*block_input*/ false); + ro_row = make_toggle_row(screen, label.c_str(), entry->get_value_base(), /*disabled*/ true, /*block_input*/ false); ro_is_toggle = ro_row != nullptr; } else if (is_enum) @@ -3316,7 +3318,7 @@ namespace big::mod_settings { // Plain string (or a widget that could not be built): greyed key + value text row. const std::string vtext = truncate_value(entry->get_serialized_value()); - ro_row = make_text_row(screen, label.c_str(), /*disabled*/ true, /*block_input*/ false); + ro_row = make_text_row(screen, label.c_str(), /*disabled*/ true, /*block_input*/ false); if (ro_row) { ro_value = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); @@ -3330,8 +3332,8 @@ namespace big::mod_settings pr.is_enabled_toggle = is_enabled_row; if (ro_is_slider || ro_is_numbox) { - pr.is_slider = ro_is_slider; // slider drag bar, or ... - pr.is_stepper = ro_is_numbox; // ... num-box stepper fallback (shares the revert path) + pr.is_slider = ro_is_slider; // slider drag bar, or ... + pr.is_stepper = ro_is_numbox; // ... num-box stepper fallback (shares the revert path) pr.stepper_min = meta->min; pr.stepper_max = meta->max; pr.stepper_step = step; @@ -3391,7 +3393,7 @@ namespace big::mod_settings } else { - row = make_numbox_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), disabled); + row = make_numbox_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), disabled); built_stepper = row != nullptr; } } @@ -3450,9 +3452,9 @@ namespace big::mod_settings build_panel_rows(screen, stem, section, collect_panel_items(stem, section)); } - #pragma endregion +#pragma endregion - #pragma region Panel sync, focus, and navigation +#pragma region Panel sync, focus, and navigation // Matches the native category-switch transition: the incoming page fades in, with no fade-out crossover. Native // UpdateScrollState sets each on-page row's mFadeTarget to 1 and off-page rows to 0, and GUIComponent::Update eases @@ -4244,9 +4246,9 @@ namespace big::mod_settings build_panel(screen, instant); } - #pragma endregion +#pragma endregion - #pragma region Reset to defaults +#pragma region Reset to defaults // The serialized default of a config entry, read from the entry itself via the public write_description (whose last // output line is "#. The serialized form uses the same converter as get_serialized_value, so it round-trips through @@ -4274,9 +4276,9 @@ namespace big::mod_settings // from the config.lua value captured by rom.mod_settings.load when available, else the entry's own stored default. static bool reset_settings_to_defaults() { - bool any_changed = false; + bool any_changed = false; const std::vector author_groups = mod_menu_groups(g_view_stem); - toml_v2::config_file* mod_cfg = nullptr; // any config file of this mod, for virtual-row path resolution. + toml_v2::config_file* mod_cfg = nullptr; // any config file of this mod, for virtual-row path resolution. for (auto* cfg : toml_v2::config_file::g_config_files) { if (!cfg || cfg->m_config_file_stem_as_str.empty() || cfg->m_config_file_stem_as_str != g_view_stem) @@ -4308,8 +4310,8 @@ namespace big::mod_settings // Skip entries outside the current menu group. The `group` override is a static field, so the cheap // stored metadata gives the placement. const auto static_meta = get_setting_metadata(guid, def.m_section, def.m_key); - const std::vector grp = static_meta ? static_meta->group : std::vector{}; - const std::string mpath = resolve_entry_menu_path(guid, author_groups, cfg, def.m_section, grp); + const std::vector grp = static_meta ? static_meta->group : std::vector{}; + const std::string mpath = resolve_entry_menu_path(guid, author_groups, cfg, def.m_section, grp); if (!menu_path_in_scope(mpath, g_view_section)) { continue; @@ -4369,9 +4371,9 @@ namespace big::mod_settings } } - #pragma endregion +#pragma endregion - #pragma region Native dialogs and dependency checks +#pragma region Native dialogs and dependency checks // True when the game's current display language uses a CJK font (zh-CN, zh-TW, ja, ko). Those fonts have no glyph for // the non-breaking space U+00A0 and draw a visible '*' instead, so the restart message uses regular spaces and a @@ -4604,9 +4606,9 @@ namespace big::mod_settings return build_list_message("These enabled mods depend on this one:", dependents, "Disable them first to disable this mod."); } - #pragma endregion +#pragma endregion - #pragma region Engine hooks +#pragma region Engine hooks static void* hook_MiscSettingsScreen_ctor(void* self, void* screen_manager, void* opened_from, void* profile_name) { @@ -4881,7 +4883,7 @@ namespace big::mod_settings { // Predict the flipped state for the press cue. get() drives the flip, but may be nil (value not set yet), // so fall back to the row's last-drawn state - matching the flip below. - const auto cur = get_virtual_value(matched_row.stem, row_io_section(&matched_row), matched_row.setting_key); + const auto cur = get_virtual_value(matched_row.stem, row_io_section(&matched_row), matched_row.setting_key); const bool cur_on = cur.type == virtual_value::kind::boolean ? cur.as_bool : matched_row.toggle_value; stage_toggle_press_sound(self, !cur_on); } @@ -5013,7 +5015,7 @@ namespace big::mod_settings float shift; if (g_rows[i].kind == RowKind::action) { - shift = extra + button_extra_lead; + shift = extra + button_extra_lead; extra += button_extra_lead + button_extra_trail; } else @@ -5328,9 +5330,9 @@ namespace big::mod_settings big::g_hooking->get_original()(self); } - #pragma endregion +#pragma endregion - #pragma region Hook registration +#pragma region Hook registration void register_hooks() { @@ -5575,6 +5577,7 @@ namespace big::mod_settings "not reset mod settings"; } } - #pragma endregion + +#pragma endregion } // namespace big::mod_settings From e21ce846c7e06d50681c6974fa4a0221dc6d8849 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:11:09 +0100 Subject: [PATCH 077/100] Update default ordering to alphabetical --- docs/mod_settings/README.md | 2 +- docs/mod_settings/config_schema.lua | 11 +- src/hades2/mod_settings/config_api.cpp | 165 ++--------------------- src/hades2/mod_settings/mod_settings.cpp | 89 ++++++++---- src/hades2/mod_settings/mod_settings.hpp | 7 +- 5 files changed, 80 insertions(+), 194 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index c306edc..9021856 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -39,7 +39,7 @@ below. Two other kinds of `configDesc` entry have their own fields and sections: | `step` | number \| callback | Slider/number step size (default 1). Will clamp user input automatically. | | `values` | array \| callback | Enum: the values stored in the `.cfg` file. If present, the input will turn into a cycler (such as for the selected display). | | `labels` | array of (string \| localization table) \| callback | Display labels parallel to `values`, only used in the in-game mod menu. | -| `order` | number \| callback | Sort key for custom ordering config entries in the menu, lower first. When omitted, rows follow their definition order in `configDesc`. | +| `order` | number \| callback | Sort key for custom ordering config entries in the menu, lower first. Rows carrying an `order` are listed above those without one. When omitted, rows are sorted alphabetically by their `displayName`. | | `hidden` | boolean | Hide the setting from the menu entirely. Static only - use `disabled` for a condition that changes while the menu is open. | | `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. See below. | | `disabledDescription` | string \| localization table \| callback | Description shown in place of `description` while the setting is greyed by its own `disabled` field, to explain why. Falls back to `description` when omitted. Not used for context-restricted or mod-disabled rows. | diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index ffec5d3..abe80b8 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -27,7 +27,8 @@ ---@field displayName? mod_settings.localized_string --- Help text shown while the category's row is highlighted. ---@field description? mod_settings.localized_string ---- Sort key among sibling categories/rows, lower first. +--- Sort key among sibling categories/rows, lower first. Entries with an `order` are listed above those without one, +--- which are sorted alphabetically by their displayName. ---@field order? number --- Nested sub-categories, keyed by their id (referenced as later path segments in a `group`). ---@field groups? table @@ -54,8 +55,8 @@ --- Display labels shown for each entry of `values` (same order, same number of entries). Each label may be a --- localization table. When omitted, the raw values are shown in the cycler. ---@field labels? mod_settings.localized_string[] | fun(): mod_settings.localized_string[] ---- Sort key for custom ordering config entries in the menu, lower first. ---- When omitted, rows keep the order they are defined in in configDesc. +--- Sort key among sibling categories/rows, lower first. Entries with an `order` are listed above those without one, +--- which are sorted alphabetically by their displayName. ---@field order? mod_settings.dynamic_number --- Hide this setting from the menu entirely. Static only (evaluated when the menu builds) - for a --- condition that changes while the menu is open, use `disabled`, which greys the setting out. @@ -172,8 +173,8 @@ --- Help text shown at the bottom of the options menu while the config rows is highlighted. Recommended to keep --- to about 35 characters so it leaves enough space for free-text input strings. ---@field description? mod_settings.dynamic_string ---- Sort key for custom ordering config entries in the menu, lower first. ---- When omitted, rows keep the order they are defined in in configDesc. +--- Sort key among sibling categories/rows, lower first. Entries with an `order` are listed above those without one, +--- which are sorted alphabetically by their displayName. ---@field order? number --- Move this row to a different or new menu category, overriding its config-section placement (see mod_settings.group). ---@field group? mod_settings.group diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 3a13684..e46fd07 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -30,46 +30,30 @@ namespace big::mod_settings { #pragma region Metadata registries and accessors - // Author-declared per-setting metadata (display name, bounds, enum options, ordering, restart flag), populated from - // each mod's config.lua by rom.mod_settings.load. Keyed by guid + '\0' + section + '\0' + key. Only settings with a - // rich-table description are registered - the rest fall back to type-based rendering. + // Author-declared per-setting metadata, populated from each mod's config.lua by rom.mod_settings.load. + // Only settings with a rich-table description are registered, the rest fall back to type-based rendering. static std::mutex g_metadata_mutex; static std::map g_setting_metadata; - // Per-setting appearance rank (a key's definition order in config.lua), for EVERY bound key. Keyed like - // g_setting_metadata. Orders rows that have no author `order`, since Lua pairs() and the config map both lose the - // source order. - static std::map g_appearance_order; - // Serialized config.lua default for every bound key, captured at load. The menu's Reset action restores this value. - // Keyed like g_setting_metadata. static std::map g_setting_default; - // (section, key) pairs that carry a configDesc entry. A key with none is hidden from the menu, except the mod's - // master "enabled" toggle (always shown). Keyed like g_setting_metadata. + // (section, key) pairs that carry a configDesc entry. A key with none is hidden from the menu. static std::set g_described_keys; - // Guids of mods that called rom.mod_settings.opt_out(), mapped to the optional custom description they passed - // (empty when none). Cleared and rebuilt on each Lua-state init because opt_out re-runs with each mod's main.lua. + // Guids of mods that called rom.mod_settings.opt_out(), mapped to the optional custom description they passed. static std::map g_opted_out_mods; - // Action buttons declared in config.lua (configDesc entries with an `action` function, no config value). Keyed by - // guid, in config.lua source order. Plain data (the callable stays in the Lua-side description registry and is - // invoked by navigation). Cleared each Lua-state init in bind_config_api. + // Action buttons declared in config.lua (configDesc entries with an `action` function). static std::map> g_actions; - // Virtual rows declared in config.lua (configDesc entries marked `virtual = true` with no backing config value). - // Keyed by guid, in config.lua source order. Like g_actions, the get/set/text callables stay in the Lua-side - // description registry and are resolved at render. Cleared per-mod in clear_metadata_for. + // Virtual rows declared in config.lua. static std::map> g_virtual_rows; - // Author-declared menu group trees (top-level configDesc `groups`), keyed by guid. These are the menu categories a - // per-entry `group` can reference that do not correspond to a config section. Cleared each Lua-state init in - // bind_config_api. mod_menu_groups returns an empty tree for mods that declared none. + // Author-declared menu group trees (top-level configDesc `groups`). These are the menu categories a per-entry + // `group` can reference that do not correspond to a config section. static std::map> g_menu_groups; - // The config section every mod's settings are bound under (matches SGG_Modding-Chalk, keeps the .cfg - // byte-compatible). Description tables in config.lua mirror the config table under this root. static constexpr const char* root_section = "config"; static std::string metadata_key(const std::string& guid, const std::string& section, const std::string& key) @@ -84,8 +68,6 @@ namespace big::mod_settings return k; } - // Drops a mod's metadata before it re-registers: config.lua may change between loads, and the Lua state is - // recreated on App::Reset (so load runs again for every mod). static void clear_metadata_for(const std::string& guid) { const std::string prefix = guid + '\0'; @@ -93,10 +75,6 @@ namespace big::mod_settings { it = (it->first.rfind(prefix, 0) == 0) ? g_setting_metadata.erase(it) : std::next(it); } - for (auto it = g_appearance_order.begin(); it != g_appearance_order.end();) - { - it = (it->first.rfind(prefix, 0) == 0) ? g_appearance_order.erase(it) : std::next(it); - } for (auto it = g_setting_default.begin(); it != g_setting_default.end();) { it = (it->first.rfind(prefix, 0) == 0) ? g_setting_default.erase(it) : std::next(it); @@ -125,21 +103,12 @@ namespace big::mod_settings return it->second; } - // True if (section, key) carries a configDesc entry (any form: a description string, a setting/action table, or a - // group table). The menu shows only described keys. An undescribed config key is hidden (see build_mod_settings). bool setting_is_described(const std::string& guid, const std::string& section, const std::string& key) { std::scoped_lock lock(g_metadata_mutex); return g_described_keys.contains(metadata_key(guid, section, key)); } - int get_setting_appearance_order(const std::string& guid, const std::string& section, const std::string& key) - { - std::scoped_lock lock(g_metadata_mutex); - const auto it = g_appearance_order.find(metadata_key(guid, section, key)); - return it != g_appearance_order.end() ? it->second : INT_MAX; - } - std::optional get_setting_default(const std::string& guid, const std::string& section, const std::string& key) { std::scoped_lock lock(g_metadata_mutex); @@ -173,57 +142,6 @@ namespace big::mod_settings #pragma endregion -#pragma region Source-order ranking helpers - - // Byte offset of a key's definition (" =") in config.lua source at or after `start` (whole-word, not "=="), - // or npos. Occurrences inside strings/prose do not match because they are not followed by a bare '='. - static std::size_t find_key_definition(const std::string& src, const std::string& key, std::size_t start = 0) - { - auto is_ident = [](char c) - { - return std::isalnum(static_cast(c)) != 0 || c == '_'; - }; - - for (std::size_t pos = src.find(key, start); pos != std::string::npos; pos = src.find(key, pos + 1)) - { - if (pos > 0 && is_ident(src[pos - 1])) - { - continue; // not a word boundary on the left (e.g. "my_key" when searching "key") - } - std::size_t after = pos + key.size(); - if (after < src.size() && is_ident(src[after])) - { - continue; // not a word boundary on the right - } - while (after < src.size() && (src[after] == ' ' || src[after] == '\t')) - { - ++after; - } - if (after < src.size() && src[after] == '=' && (after + 1 >= src.size() || src[after + 1] != '=')) - { - return pos; - } - } - return std::string::npos; - } - - // Rank position for a described entry: where it appears in configDesc, so the menu's fallback order follows the - // author's layout rather than the `config` defaults table. A config-backed key appears in `config` first and again - // in configDesc, so the SECOND occurrence is taken; a virtual row or action only ever appears once. Returns npos - // (sorts last) when the key cannot be located, e.g. a numeric or bracketed key. - static std::size_t desc_definition_offset(const std::string& src, const std::string& key, bool config_backed) - { - const std::size_t first = find_key_definition(src, key); - if (!config_backed || first == std::string::npos) - { - return first; - } - const std::size_t second = find_key_definition(src, key, first + 1); - return second != std::string::npos ? second : first; - } - -#pragma endregion - #pragma region Config.lua parsing helpers static std::string serialize_option(const sol::object& v); // defined below. @@ -1503,66 +1421,7 @@ namespace big::mod_settings menu_groups = parse_menu_groups(descriptions.as()["groups"]); } - // Read config.lua source to recover the author's key order (Lua pairs() and the alphabetical config map both - // lose it), then rank every bound key by where it is defined. - std::string source_text; - { - std::ifstream file(config_lua_path, std::ios::binary); - if (file) - { - std::ostringstream ss; - ss << file.rdbuf(); - source_text = ss.str(); - } - } - // Rank every described entry by where it appears in configDesc (the menu-layout table), so an author who sets no - // explicit `order` gets the rows in the order they laid out in configDesc. Config-backed keys are located via - // their configDesc entry (their second source occurrence), virtual rows and actions via their only one. - std::vector> by_offset; // (offset, section, key). - for (const auto& [def, entry] : cf->m_entries) - { - const std::size_t off = source_text.empty() ? std::string::npos : desc_definition_offset(source_text, def.m_key, true); - by_offset.emplace_back(off, def.m_section, def.m_key); - } - for (const auto& vr : virtual_rows) - { - const std::size_t off = source_text.empty() ? std::string::npos : desc_definition_offset(source_text, vr.key, false); - by_offset.emplace_back(off, vr.section, vr.key); - } - for (const auto& a : actions) - { - const std::size_t off = source_text.empty() ? std::string::npos : desc_definition_offset(source_text, a.key, false); - by_offset.emplace_back(off, a.section, a.key); - } - std::stable_sort(by_offset.begin(), - by_offset.end(), - [](const auto& a, const auto& b) - { - return std::get<0>(a) < std::get<0>(b); - }); - - // Order the collected actions by their position in the config.lua source so their menu order is deterministic - // and matches how the author wrote them (collect_actions walks in Lua pairs order, which is unspecified). - std::stable_sort(actions.begin(), - actions.end(), - [&](const action_info& a, const action_info& b) - { - const std::size_t oa = source_text.empty() ? std::string::npos : find_key_definition(source_text, a.key); - const std::size_t ob = source_text.empty() ? std::string::npos : find_key_definition(source_text, b.key); - return oa < ob; - }); - - // Same for virtual rows. - std::stable_sort(virtual_rows.begin(), - virtual_rows.end(), - [&](const virtual_row_info& a, const virtual_row_info& b) - { - const std::size_t oa = source_text.empty() ? std::string::npos : find_key_definition(source_text, a.key); - const std::size_t ob = source_text.empty() ? std::string::npos : find_key_definition(source_text, b.key); - return oa < ob; - }); - - // Register this mod's setting metadata + appearance order (replacing any from a previous load of the same mod). + // Register this mod's setting metadata (replacing any from a previous load of the same mod). { std::scoped_lock lock(g_metadata_mutex); clear_metadata_for(guid); @@ -1578,11 +1437,6 @@ namespace big::mod_settings { g_described_keys.insert(metadata_key(guid, section, key)); } - int rank = 0; - for (const auto& [off, section, key] : by_offset) - { - g_appearance_order[metadata_key(guid, section, key)] = rank++; - } g_actions[guid] = std::move(actions); g_virtual_rows[guid] = std::move(virtual_rows); g_menu_groups[guid] = std::move(menu_groups); @@ -1996,7 +1850,6 @@ namespace big::mod_settings { std::scoped_lock lock(g_metadata_mutex); g_setting_metadata.clear(); - g_appearance_order.clear(); g_setting_default.clear(); g_opted_out_mods.clear(); g_actions.clear(); diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index f6c9f84..244c635 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -621,6 +621,33 @@ namespace big::mod_settings return out; } + static int compare_display_names(const std::string& a, const std::string& b) + { + const std::size_t n = std::min(a.size(), b.size()); + for (std::size_t i = 0; i < n; ++i) + { + unsigned char ca = static_cast(a[i]); + unsigned char cb = static_cast(b[i]); + if (ca >= 'A' && ca <= 'Z') + { + ca = static_cast(ca + ('a' - 'A')); + } + if (cb >= 'A' && cb <= 'Z') + { + cb = static_cast(cb + ('a' - 'A')); + } + if (ca != cb) + { + return ca < cb ? -1 : 1; + } + } + if (a.size() == b.size()) + { + return 0; + } + return a.size() < b.size() ? -1 : 1; + } + // --- Text metrics + caret helpers (byte indices into a string UTF-8 aware) --- Approximate width of a single byte in // the value font, in the same units as value_display_max_width (medium glyph. static float glyph_weight(unsigned char c) @@ -1732,7 +1759,7 @@ namespace big::mod_settings mods.end(), [](const auto& a, const auto& b) { - return a.first < b.first; + return compare_display_names(a.first, b.first) < 0; }); for (const auto& [display, stem] : mods) @@ -2610,7 +2637,7 @@ namespace big::mod_settings localized_text author_description; // author-group description (is_author_group only) bool has_order = false; double order = 0.0; - int appearance = INT_MAX; // config.lua source rank (fallback order) + std::string sort_name; // resolved display name, the alphabetical fallback sort key bool is_enabled = false; // the mod's master "enabled" toggle (root section only) bool is_action = false; // a config.lua action button (runs a Lua callback, no config value) action_info action; // valid when is_action @@ -2635,7 +2662,7 @@ namespace big::mod_settings panel_contents out; std::vector& items = out.items; - std::map groups; // child menu path -> group item (keeps its min appearance). + std::map groups; // child menu path -> group item toml_v2::config_file*& view_cfg = out.view_cfg; // this mod's config file (for child lookups) const std::string section_prefix = section + "."; @@ -2666,40 +2693,46 @@ namespace big::mod_settings return 0; }; - // Creates (or ranks lower) the child group row at `child_path`. A group declared in configDesc `groups` takes - // its name/order/description from there; otherwise it is config-derived and resolved in the render. - auto ensure_group = [&](const std::string& child_path, int app) + // Creates the child group row at `child_path` (no-op if it already exists). A group declared in configDesc + // `groups` takes its name/order/description from there; otherwise it is config-derived and resolved in the + // render. Its sort name mirrors the label the render picks, so the alphabetical fallback matches what is shown. + auto ensure_group = [&](const std::string& child_path) { - if (const auto git = groups.find(child_path); git != groups.end()) + if (groups.contains(child_path)) { - if (app < git->second.appearance) - { - git->second.appearance = app; - } return; } panel_item g; g.is_group = true; g.child_section = child_path; - g.appearance = app; g.key = child_path.substr(child_path.rfind('.') + 1); // the child's last path segment if (const menu_group* ag = find_author_group(author_groups, child_path)) { g.is_author_group = true; g.author_name = ag->name; g.author_description = ag->description; + g.sort_name = resolve_localized(ag->name); if (ag->has_order) { g.has_order = true; g.order = ag->order; } } - else if (const auto meta = resolved_metadata(stem, section, g.key); meta && meta->has_order && !config_child_exists(view_cfg, child_path, "order")) + else if (const auto meta = resolved_metadata(stem, section, g.key); meta) { + g.sort_name = resolve_localized(meta->name); + // Config-derived group: its metadata is configDesc.
., resolved here for order and again // in the render for name/description. Defers to a real config child named "order". - g.has_order = true; - g.order = meta->order; + if (meta->has_order && !config_child_exists(view_cfg, child_path, "order")) + { + g.has_order = true; + g.order = meta->order; + } + } + if (g.sort_name.empty()) + { + g.sort_name = key_to_display(g.key); } groups.emplace(child_path, std::move(g)); }; @@ -2745,7 +2778,7 @@ namespace big::mod_settings it.key = key.m_key; it.entry = entry.get(); it.config_section = key.m_section; - it.appearance = get_setting_appearance_order(stem, key.m_section, key.m_key); + it.sort_name = setting_display_name(stem, key.m_section, key.m_key); if (const auto meta = resolved_metadata(stem, key.m_section, key.m_key); meta && meta->has_order) { it.has_order = true; @@ -2755,7 +2788,7 @@ namespace big::mod_settings } else if (place == 2) { - ensure_group(child_path, get_setting_appearance_order(stem, key.m_section, key.m_key)); + ensure_group(child_path); } } } @@ -2773,7 +2806,7 @@ namespace big::mod_settings const int place = placement(a.section, a.group, child_path); if (place == 2) { - ensure_group(child_path, get_setting_appearance_order(stem, a.section, a.key)); + ensure_group(child_path); continue; } if (place != 1) @@ -2786,8 +2819,12 @@ namespace big::mod_settings it.config_section = a.section; it.has_order = a.has_order; it.order = a.order; - it.appearance = get_setting_appearance_order(stem, a.section, a.key); - it.action = std::move(a); + it.sort_name = resolve_localized(a.name); + if (it.sort_name.empty()) + { + it.sort_name = key_to_display(a.key); + } + it.action = std::move(a); items.push_back(std::move(it)); } @@ -2799,7 +2836,7 @@ namespace big::mod_settings const int place = placement(vr.section, vr.group, child_path); if (place == 2) { - ensure_group(child_path, get_setting_appearance_order(stem, vr.section, vr.key)); + ensure_group(child_path); continue; } if (place != 1) @@ -2817,7 +2854,7 @@ namespace big::mod_settings it.config_section = vr.section; it.has_order = vr.has_order; it.order = vr.order; - it.appearance = get_setting_appearance_order(stem, vr.section, vr.key); + it.sort_name = setting_display_name(stem, vr.section, vr.key); items.push_back(std::move(it)); } @@ -2833,9 +2870,9 @@ namespace big::mod_settings } } - // Row order: the master "enabled" toggle is pinned to the top then rows with an author `order` (ascending) then - // the rest by configDesc source order (a group's rank is its earliest-defined descendant's, so a drill-in sits - // where its content is declared rather than being pinned above the settings). + // Row order: the master "enabled" toggle is pinned to the top, then rows carrying an authored `order` (ascending), + // then everything else alphabetically by its displayed name. Sorting on the display name (not the config key) + // keeps the list alphabetical in whatever language is active, matching what the player actually reads. std::stable_sort(items.begin(), items.end(), [](const panel_item& a, const panel_item& b) @@ -2856,7 +2893,7 @@ namespace big::mod_settings { return a.order < b.order; } - return a.appearance < b.appearance; // equal/absent order -> configDesc source order + return compare_display_names(a.sort_name, b.sort_name) < 0; }); return out; diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 7e5feed..7b0d9ef 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -191,7 +191,7 @@ namespace big::mod_settings }; // The virtual (non-config) rows declared directly in config `section` of mod `guid` (not recursing into child - // sections), in config.lua source order. + // sections). The order is unspecified (the menu sorts rows itself, by `order` then display name). std::vector get_virtual_rows(const std::string& guid, const std::string& section); // The display string for a READ-ONLY virtual row, from its `text` (a string or a function returning one) callback. @@ -230,11 +230,6 @@ namespace big::mod_settings // backed settings recover their own defaults separately). Call on the game thread while the Lua state is alive. bool reset_virtual_row_to_default(const std::string& guid, const std::string& section, const std::string& key); - // Rank of a setting's definition in its config.lua source (0 = first). Used to order rows that have no - // author-declared `order` in config-file order. Returns INT_MAX for keys not bound via rom.mod_settings.load (e.g. - // Chalk-bound), so they fall back to the config map order. - int get_setting_appearance_order(const std::string& guid, const std::string& section, const std::string& key); - // Returns the config.lua default (serialized like the config entry's value) for a setting bound via // rom.mod_settings.load, or std::nullopt for keys with no captured default. Used by the settings menu's. Reset // action to restore a setting to what config.lua declared. From 41b47dfe4e2a96d181861084c36db03ae0c38ae2 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:51:23 +0100 Subject: [PATCH 078/100] Condensed comments --- src/hades2/mod_settings/config_api.cpp | 107 ++-- src/hades2/mod_settings/mod_settings.cpp | 659 ++++++----------------- src/hades2/mod_settings/mod_settings.hpp | 149 ++--- 3 files changed, 265 insertions(+), 650 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index e46fd07..3fc81c9 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -172,8 +172,7 @@ namespace big::mod_settings } // Resolves a localized string to a single language-independent value for on-disk use (the .cfg comment), which is - // not re-written per language: English, then the unlocalized value, then any entry. The in-game menu resolves to - // the live game language separately at render time. + // not re-written per language: English, then the unlocalized value, then any entry. static std::string localized_fallback(const localized_text& t) { if (t.empty()) @@ -192,8 +191,8 @@ namespace big::mod_settings } // Extracts the (possibly localized) description from a config.lua description value, which may be a plain string, - // or a rich table with a `description` field (or `[1]` shorthand) that is itself a plain string or a localization - // table. + // or a rich table with a `description` field (or `[1]` shorthand for backwards-compatibility) that is itself a + // plain string or a localization table. static localized_text describe(const sol::object& desc) { if (desc.get_type() == sol::type::string) @@ -361,22 +360,16 @@ namespace big::mod_settings #pragma region Metadata extraction - // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. Captures the - // author-only inputs that can't be inferred (name, bounds, enum options/labels, order, hidden, restart). The widget + // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. The widget // kind is not stored - the menu derives it from the value's type plus the presence of `values` (enum). static setting_metadata extract_metadata(const sol::table& desc) { setting_metadata m; m.description = describe(desc); - // Display-name override (`displayName`) empty -> the menu prettifies the key. May be a plain string or a - // localization table. sol::object display_name = desc["displayName"]; m.name = parse_localized(display_name); - // Alternative description shown while the row is greyed by its `disabled` field (empty -> fall back to the - // normal description). String or localization table, like displayName. If it was written as a function it has - // already been resolved to a concrete value by resolve_description before this runs. m.disabled_description = parse_localized(desc["disabledDescription"]); sol::object min_field = desc["min"]; @@ -405,7 +398,6 @@ namespace big::mod_settings return serialize_option(v); }); - // Enum option display labels (parallel to `values`). Each may be a plain string or a localization table. if (sol::object labels_obj = desc["labels"]; labels_obj.is()) { sol::table lt = labels_obj.as(); @@ -477,21 +469,19 @@ namespace big::mod_settings } } - // Virtual-row `default`: the value a menu Reset restores the row to (config settings recover their own default - // from the .cfg / config.lua). Serialized the same way as an enum option value so it round-trips through set(). + // Virtual-row `default`: the value a menu Reset restores the row to (config settings recover their own default). if (sol::object default_field = desc["default"]; default_field.valid() && default_field.get_type() != sol::type::lua_nil) { m.has_default = true; m.default_value = serialize_option(default_field); } - // When the setting may be changed relative to a loaded save (`editableContext`). The menu forces the master - // "enabled" toggle and restartRequired settings to main_menu regardless. + // When the setting may be changed relative to a loaded save (`editableContext`). The menu forces the + // "enabled" toggle and any restartRequired settings to main_menu regardless. m.context = parse_editable_context(desc["editableContext"], editable_context::any); // A field written as a Lua function is dynamic: skipped by the type-guarded reads above and re-evaluated at - // render. `hidden` is deliberately not dynamic (toggling it shifts layout, so it is only re-done on a full - // rebuild - use `disabled` for a live condition), and `editableContext` is a fixed design property. + // render. for (const char* field : {"displayName", "description", "disabledDescription", "min", "max", "step", "values", "labels", "order", "disabled"}) { if (desc[field].get_type() == sol::type::function) @@ -513,7 +503,7 @@ namespace big::mod_settings // The Lua-side registry (rom.mod_settings._descs) mapping guid -> the mod's raw configDesc table, kept alive so // dynamic description fields and action callbacks can be evaluated at render. Recreated each Lua state, so it never - // dangles. Returns a nil object if the guid has no stored description. + // dangles. static sol::object stored_descriptions(sol::state_view state, const std::string& guid) { sol::object ns = state[rom::g_lua_api_namespace]; @@ -535,8 +525,7 @@ namespace big::mod_settings } // Navigates a mod's stored configDesc to the description of (section, key). The configDesc mirrors the config table - // under the "config" root, so the section's remaining path (after "config") indexes nested description tables, then - // `key` selects the leaf/group description. Returns nil if any hop is missing or not a table. + // under the "config" root, so the section's remaining path indexes nested description tables. static sol::object navigate_description(const sol::object& root, const std::string& section, const std::string& key) { if (!root.is()) @@ -546,7 +535,6 @@ namespace big::mod_settings sol::table node = root.as(); - // section is "config" or "config.a.b..." walk the part after the root. std::string rel; if (section.size() > std::strlen(root_section) && section.compare(0, std::strlen(root_section) + 1, std::string(root_section) + ".") == 0) { @@ -574,7 +562,7 @@ namespace big::mod_settings // A minimal Lua message handler that returns the error object unchanged. Unlike ReturnOfModding's global default // handler it neither appends a stack traceback nor logs the failure at ERROR (and does not count it against the - // mod's error tally), leaving the raw one-line error for our own concise WARNING to report. + // mod's error tally). static int silent_error_handler(lua_State* /*L*/) { return 1; // keep the single error value already on the stack. @@ -659,7 +647,7 @@ namespace big::mod_settings // Walks a mod's configDesc (guided by the config defaults, like bind_defaults) collecting action buttons - // description entries carrying an `action` function and no config value. Recurses into groups so actions can live at - // any level. Dynamic fields (has_dynamic) are re-resolved at render by get_actions. + // any level. static void collect_actions(const sol::table& config_tbl, const sol::object& desc_obj, const std::string& section, std::vector& out) { if (desc_obj.is()) @@ -821,7 +809,7 @@ namespace big::mod_settings const bool has_text = t.get_type() == sol::type::string || t.get_type() == sol::type::function; // A row is interactive (an editable get/set widget) when it has a `set`, otherwise it is a read-only - // `text` row. get/set/text/values/min/max may be functions too (dynamic), so re-evaluate at render. + // `text` row. vr.interactive = has_set; for (const char* field : {"displayName", "description", "text", "values", "min", "max", "step", "labels"}) { @@ -838,7 +826,6 @@ namespace big::mod_settings if (vr.interactive) { - // Interactive row: needs `get` to read its current value for the widget. `set` is present here. if (!has_get) { LOG(WARNING) << "[mod_settings] " << guid << ": interactive virtual row '" << path << "' has a `set` but no `get`, so its widget cannot read a value; add a `get` callback."; @@ -847,7 +834,6 @@ namespace big::mod_settings } else if (!has_text) { - // Read-only row: needs `text`. (A stray `get` with no `set` is not a display path.) LOG(WARNING) << "[mod_settings] " << guid << ": virtual row '" << path << "' has no `text` (a string or a function returning one) and no `set` (to be interactive), so it has nothing to show."; } out.push_back(std::move(vr)); @@ -870,8 +856,7 @@ namespace big::mod_settings #pragma region Config entry access and change hooks - // Finds the config entry for (section, key), or nullptr. m_entries is keyed by config_definition, so this is a - // direct map lookup. + // Finds the config entry for (section, key), or nullptr. static toml_v2::config_file::config_entry_base* find_entry(toml_v2::config_file* cf, const std::string& section, const std::string& key) { toml_v2::config_definition def(section, key); @@ -912,8 +897,7 @@ namespace big::mod_settings return sol::lua_nil; } - // Writes a Lua value into a config entry, dispatching on the value's Lua type (matching the toml_v2 - // config_entry:set overloads: bool/number/string). + // Writes a Lua value into a config entry, dispatching on the value's Lua type. static void entry_set(toml_v2::config_file::config_entry_base* entry, const sol::object& value) { switch (value.get_type()) @@ -953,7 +937,7 @@ namespace big::mod_settings } // Parses a config key that is a positive-integer array index ("1", "2", ...), used to expose array-like sections - // through #, ipairs and inext. Returns false for an empty or non-digit key. + // through #, ipairs and inext. static bool parse_positive_index(const std::string& key, long& out) { if (key.empty()) @@ -1159,8 +1143,7 @@ namespace big::mod_settings return mod_config_proxy{cf, section}; } - // Coerces a Lua index key to the string form config entries use (Chalk stringifies numeric keys). Returns false for - // a key that is neither a string nor a number. + // Coerces a Lua index key to the string form config entries use (Chalk stringifies numeric keys). static bool coerce_key(const sol::stack_object& key, std::string& out) { if (key.get_type() == sol::type::string) @@ -1225,8 +1208,7 @@ namespace big::mod_settings #pragma region Default binding and config.lua load - // A setting's extracted metadata together with the section/key it belongs to, collected while walking config.lua - // and then folded into the registry. + // A setting's extracted metadata together with the section/key it belongs to, collected while walking config.lua. struct collected_metadata { std::string section; @@ -1284,29 +1266,25 @@ namespace big::mod_settings default: continue; } - // Capture the config.lua default, serialized exactly as the entry serializes its own value, so the menu's. + // Capture the config.lua default, serialized exactly as the entry serializes its own value, so the menu's // Reset can round-trip it back through set_serialized_value. if (default_any) { defaults_out.emplace_back(section, key, toml_v2::toml_type_converter::convert_to_string(*default_any)); - // Record a described leaf so the menu shows it. An undescribed leaf is hidden. Only leaves reach here - // (default_any is set for bool/number/string, not a group table). + // An undescribed leaf is hidden from the menu. if (described) { described_out.emplace_back(section, key); } } - // A rich description table carries metadata. For a leaf it is the setting's metadata. For a nested group (a - // table value). It is group-level metadata (e.g. order/displayName/hidden) declared alongside the child - // descriptions. Registered under (section, key) either way. + // A rich description table carries metadata: the setting's own for a leaf, or group-level metadata (order, + // displayName, ...) for a nested group. Registered under (section, key) either way. if (desc.is()) { meta_out.push_back({section, key, extract_metadata(desc.as())}); - // A leaf may also declare an onChanged callback. Attach it to the bound entry so a menu edit (or the - // mod's own write) of this setting notifies the mod in Lua. if (bound_entry) { sol::object on_changed = desc.as()["onChanged"]; @@ -1325,8 +1303,6 @@ namespace big::mod_settings // of the Options menu. Replaces depending on `Chalk`. static sol::object load(sol::this_state ts, sol::this_environment this_env, const std::string& config_lua) { - // Derives the mod's /.cfg, creates a config_file owned by the mod, runs its config.lua, binds the - // defaults/descriptions, records restart-required settings, and returns the proxy. if (!this_env) { return sol::lua_nil; @@ -1350,10 +1326,8 @@ namespace big::mod_settings const std::string cfg_folder = config_folder(); const std::string cfg_path = path_combine(cfg_folder, guid + ".cfg"); - // Create the config_file owned by this mod (freed when the mod unloads). auto& cf = module->m_data.m_config_files.emplace_back(std::make_unique(cfg_path, true, guid)); - // Load the mod's config.lua (returns `config, configDesc`), relative to its folder. const std::string mod_folder = env["_PLUGIN"]["plugins_mod_folder_path"]; const std::string config_lua_path = mod_folder + "/" + config_lua; @@ -1376,10 +1350,9 @@ namespace big::mod_settings sol::object defaults = cfg_result[0]; sol::object descriptions = cfg_result[1]; - // Bind the defaults into the config_file (section root "config", matching. Chalk) and collect each rich - // setting's metadata, then persist the file. + // Section root is "config", matching Chalk, so an existing .cfg stays byte-compatible. std::vector collected; - std::vector> collected_defaults; // (section. + std::vector> collected_defaults; // (section, key, serialized) std::vector> collected_described; // (section, key) with a desc if (defaults.is()) { @@ -1388,8 +1361,8 @@ namespace big::mod_settings cf->save(); // Keep this mod's configDesc alive in Lua so the menu can evaluate dynamic (function) description fields and - // action callbacks at render time. Stored under rom.mod_settings._descs[guid], which is Lua-owned and recreated - // per state, so no sol reference is cached in a dangling C++ static. + // action callbacks at render time. Lua-owned and recreated per state, so no sol reference dangles in a C++ + // static. if (sol::object ms_ns = rom["mod_settings"]; ms_ns.is()) { if (sol::object descs = ms_ns.as()["_descs"]; descs.is()) @@ -1398,23 +1371,21 @@ namespace big::mod_settings } } - // Collect action buttons declared in configDesc (entries with an `action` function no config value). + // Collect action buttons declared in configDesc (entries with an `action` function and no config value). std::vector actions; if (defaults.is()) { collect_actions(defaults.as(), descriptions, root_section, actions); } - // Collect virtual rows (configDesc entries marked `virtual = true` with no config value) and validate that - // every configDesc entry resolves to a config value, an action, or a virtual marker (warns otherwise). + // Also validates that every configDesc entry resolves to a config value, an action, or a virtual marker. std::vector virtual_rows; if (defaults.is()) { collect_virtual_rows(guid, defaults.as(), descriptions, root_section, virtual_rows); } - // Parse the author-declared menu group tree (top-level configDesc `groups`), the categories a per-entry `group` - // can target that do not exist as config sections. + // The categories a per-entry `group` can target that do not exist as config sections. std::vector menu_groups; if (descriptions.is()) { @@ -1469,8 +1440,7 @@ namespace big::mod_settings } // True when the game is in the hub (the Crossroads), i.e. the game Lua global `CurrentHubRoom` is non-nil. Reads - // the game's Lua state directly, so it must be called on the game thread while the state is alive. Returns false - // when the Lua manager is not up yet. + // the game's Lua state directly, so it must be called on the game thread while the state is alive. bool game_is_in_hub() { if (!big::g_lua_manager) @@ -1501,8 +1471,7 @@ namespace big::mod_settings } } - // Re-evaluate any dynamic fields (name/description/order/disabled) against the current game state, mirroring - // resolve_setting_metadata for settings. + // Re-evaluate any dynamic fields against the current game state, mirroring resolve_setting_metadata. if (big::g_lua_manager) { sol::state_view state = big::g_lua_manager->lua_state(); @@ -1584,9 +1553,7 @@ namespace big::mod_settings } sol::table t = desc.as(); - // The read-only display comes from `text`: a plain string, or a function returning a bool/number/string that - // is stringified. Evaluated protected so a mod error cannot crash the menu. (`get`/`set` is the separate - // editable value pair, added with interactive virtual rows - it is not a display path.) + // `text` is a plain string, or a function returning a bool/number/string that is stringified. const sol::object text = t["text"]; if (text.get_type() == sol::type::string) { @@ -1706,7 +1673,7 @@ namespace big::mod_settings } // The virtual_value kind an author-forced widget_type maps to (enum options and strings are both carried as - // strings). widget_type::inferred / an unmapped value yield kind::none. + // strings). static virtual_value::kind kind_of_widget(widget_type t) { switch (t) @@ -1719,7 +1686,7 @@ namespace big::mod_settings } } - // Builds a typed virtual_value from a serialized scalar for the given kind. kind::none yields a none value. + // Builds a typed virtual_value from a serialized scalar for the given kind. static virtual_value virtual_value_from_serialized(virtual_value::kind kind, const std::string& serialized) { virtual_value v; @@ -1886,9 +1853,9 @@ namespace big::mod_settings ns.set_function("load", &load); ns.set_function("opt_out", &opt_out); - // A Lua-owned table holding each mod's raw configDesc (rom.mod_settings._descs[guid]), so the menu can evaluate - // dynamic (function) description fields and action callbacks at render time without caching sol references in. - // C++ statics (which would dangle across a Lua-state reset). + // A Lua-owned table holding each mod's raw configDesc, so the menu can evaluate dynamic description fields and + // action callbacks at render time without caching sol references in C++ statics (which would dangle across a + // Lua-state reset). ns["_descs"] = state.create_table(); } diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 244c635..ebb16d8 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -80,7 +80,6 @@ namespace big::mod_settings static constexpr std::size_t def_fade_speed = 0x2'1C; // mFadeSpeed (float) opacity ease rate (component +0x2C4) // GUIComponent::Update moves mFadeOpacity toward mFadeTarget by dt * mFadeSpeed, so this drives the fade timing. - // Applied to every row so all row types fade at one uniform speed. static constexpr float row_fade_speed = 10.0f; // sgg::MessageDialog, the single-button box the game uses in the MAIN MENU for save/file errors. @@ -93,29 +92,24 @@ namespace big::mod_settings static constexpr std::size_t dialog_confirm_button_offset = 0x1'A0; // sgg::MenuScreen::mConfirmButton static constexpr std::size_t dialog_message_offset = 0x2'B0; // sgg::MessageDialog::mMessageText - // The MessageDialog.sjson MessageText template renders at FontSize 26, too large for the multi-line body. Scaling - // the font handle's ratios shrinks it. + // The MessageDialog.sjson MessageText template renders at FontSize 26, too large for the multi-line body. static constexpr std::size_t textbox_font_handle_offset = 0x6'A4; // GUIComponentTextBox::mFontHandle static constexpr std::size_t font_handle_size_ratio_offset = 0x0C; // sgg::FontHandle::mFontSizeRatio static constexpr std::size_t font_handle_eng_size_ratio_offset = 0x10; // sgg::FontHandle::mEnglishFontSizeRatio static constexpr float restart_message_font_scale = 0.75f; // ~26 -> ~19.5 - // Module-relative RVAs for overloaded functions the PDB map cannot disambiguate. Resolved from - // anchor_runtime - anchor_rva + target_rva. AddScreen's 4-arg overload appends so the dialog draws on top. + // Module-relative RVAs for overloaded functions the PDB map cannot disambiguate. static constexpr std::uintptr_t anchor_rva = 0x11'5C'70; // GUIComponentButton::GUIComponentButton static constexpr std::uintptr_t message_dialog_ctor_rva = 0x16'EE'60; // sgg::MessageDialog::MessageDialog static constexpr std::uintptr_t add_screen_rva = 0x14'7D'D0; // sgg::ScreenManager::AddScreen - // tf_new_internal: the game's own factory, which allocates the - // num-box and builds its 5 sub-components. A template instantiation, so resolved by RVA off the anchor. + // tf_new_internal: the game's own num-box factory. static constexpr std::uintptr_t numbox_factory_rva = 0x17'A5'30; - // eastl::vector::push_back, used only as a fallback when the named PDB symbol is missing (it is - // sometimes emitted inline). Resolved off the same button-ctor anchor. + // eastl::vector::push_back, used only as a fallback when the named PDB symbol is missing. static constexpr std::uintptr_t push_back_rva = 0x14'1E'D0; - // sgg::MenuScreen::TeleportCursorTo(this, GUIComponent*) - the 2-arg overload that drops the controller/keyboard - // free-form cursor onto a component. + // sgg::MenuScreen::TeleportCursorTo(this, GUIComponent*) - the 2-arg overload. static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; // The config/control globals (ConfigOptions::UseMouse/Language, Controls::Cancel/Select) live in .data/.rdata, @@ -162,13 +156,12 @@ namespace big::mod_settings // The Button_Secondary sprite's native atlas width in px. The box draws at native * mScale * mScaleX. static constexpr float button_graphic_native_width = 350.0f; - // Approximate label capacity of the box at its native width, in measure_width glyph units. A longer label stretches - // the box just enough to fit, so short buttons keep the clean native box and only long ones distort. + // Approximate label capacity of the box at its native width, in measure_width glyph units. static constexpr float button_label_capacity = 15.0f; static constexpr float button_label_padding = 2.0f; // sgg::GUIComponentSlider, the audio-volume drag bar. DoShowCategory hand-builds it, so make_slider_row does too. - // The vtable is preferred by name; this RVA is a .rdata fallback and must be refreshed when the build changes. + // The vtable is preferred by name. This RVA is a .rdata fallback and must be refreshed when the build changes. static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'68; static constexpr std::size_t slider_sizeof = 0x5'B0; static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking/mFill) @@ -194,19 +187,17 @@ namespace big::mod_settings static constexpr std::size_t vtable_set_location_offset = 0x1'80; // GUIComponent::SetLocation slot (moves the component and its children) // Greying a slider/num-box: the button-style def greying does not reach their separate label/value text boxes or - // bar/arrow graphics, so each is greyed directly. A text box renders mDisabledText only when its def carries a - // non-negative disabled colour, so that is written explicitly; the value box is flagged too, since Draw never - // touches it. An image tints from mColor every frame, so mColorTarget is written as well or a lerp undoes it. - static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor - // Greying the normal and selected colours too keeps the label grey in every state, matching set_def_text_grey. - static constexpr std::size_t textbox_text_red = 0x1'B4; // mData.mDef.mTextRed (float) - static constexpr std::size_t textbox_selected_text_red = 0x1'D0; // mData.mDef.mSelectedTextRed (float) - static constexpr std::size_t textbox_disabled_text_red = 0x1'E8; // mData.mDef.mDisabledTextRed (float) - static constexpr std::size_t textbox_disabled_text_green = 0x1'EC; // mDisabledTextGreen (float) - static constexpr std::size_t textbox_disabled_text_blue = 0x1'F0; // mDisabledTextBlue (float) - static constexpr std::size_t textbox_disabled_text_alpha = 0x1'F4; // mDisabledTextAlpha (float) - static constexpr std::size_t image_color_offset = 0x5'44; // GUIComponentImage::mColor (packed RGBA) - static constexpr std::size_t image_color_target_offset = 0x00'78; // mColorTarget (packed RGBA) + // bar/arrow graphics, so each is greyed directly. An image tints from mColor every frame, so mColorTarget is written + // as well or a lerp undoes it. + static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor + static constexpr std::size_t textbox_text_red = 0x1'B4; // mData.mDef.mTextRed (float) + static constexpr std::size_t textbox_selected_text_red = 0x1'D0; // mData.mDef.mSelectedTextRed (float) + static constexpr std::size_t textbox_disabled_text_red = 0x1'E8; // mData.mDef.mDisabledTextRed (float) + static constexpr std::size_t textbox_disabled_text_green = 0x1'EC; // mDisabledTextGreen (float) + static constexpr std::size_t textbox_disabled_text_blue = 0x1'F0; // mDisabledTextBlue (float) + static constexpr std::size_t textbox_disabled_text_alpha = 0x1'F4; // mDisabledTextAlpha (float) + static constexpr std::size_t image_color_offset = 0x5'44; // GUIComponentImage::mColor (packed RGBA) + static constexpr std::size_t image_color_target_offset = 0x00'78; // mColorTarget (packed RGBA) static constexpr std::size_t button_graphic_color_offset = 0x5'5C; // GUIComponentButton::mButtonColor - the colour Draw paints the toggle graphic with static constexpr std::size_t component_color_target_offset = 0x00'78; // GUIComponent::mColorTarget (Update eases mButtonColor toward this) static constexpr std::size_t def_sel_red = 0xFC; // ComponentDataDef::mSelectedRed - set <0 to disable the selected-colour override in Draw/On(Un)Selected @@ -217,12 +208,10 @@ namespace big::mod_settings static constexpr std::size_t vtable_set_text_color_offset = 0x1'60; static constexpr std::uint32_t disabled_label_grey_packed = 0xFF'38'38'38; - // NumBox::OnSelected turns the box black by writing the selected colour into the animation's own mColor. static constexpr std::size_t animation_color_offset = 0x5'58; // GUIComponentAnimation::mColor (packed ARGB) static constexpr std::uint32_t numbox_hover_bg_black = 0xFF'00'00'00; // the num-box's hovered/selected box colour - // Called with flags = 0 it destructs and frees owned sub-components without the final operator delete, so the block - // itself is freed separately (see game_free). + // Flags = 0 destructs owned sub-components without the final operator delete, so the block is freed separately. static constexpr std::size_t vtable_deleting_dtor_offset = 0x1'88; using ctor_fn = void* (*)(void* button, void* owner_screen); @@ -245,8 +234,7 @@ namespace big::mod_settings using numbox_set_range_fn = void (*)(void* num_box, float min, float max); using numbox_set_value_fn = void (*)(void* num_box, float value, bool notify); - // GUIComponent-derived constructors take the initial location as a Vec2 passed by value (packed into a single. - // 64-bit register). 0 is the origin. Used to hand-build a slider and its sub-components. + // GUIComponent-derived constructors take the initial location as a Vec2 passed by value in one 64-bit register. using gui_component_ctor_fn = void (*)(void* self, std::uint64_t location_packed); using slider_defaults_fn = void (*)(void* slider); using slider_set_fraction_fn = void (*)(void* slider, float fraction, bool notify); @@ -330,9 +318,7 @@ namespace big::mod_settings static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) static void* g_active_profile = nullptr; // &sgg::ProfileManager::ACTIVE_PROFILE - // Set true by register_hooks only once every engine symbol, RVA and offset the Mods tab needs has resolved for the - // running game build. While false no hooks are installed and the tab is absent. It also gates process-global side - // effects (the wndproc callback) as a safety net. + // Set true by register_hooks only once every engine symbol, RVA and offset the Mods tab needs has resolved. static bool g_feature_enabled = false; // sgg::KeyboardButtonId values used for edit confirm/cancel (validated in the PDB). @@ -340,8 +326,7 @@ namespace big::mod_settings static constexpr int key_kp_enter = 113; static constexpr int key_return = 127; - // Hash of the game's "Blank" (empty) graphic, resolved once, used to hide a row's button background so it renders - // as a plain text label. + // Hash of the game's "Blank" graphic, used to hide a row's button background. static std::uint32_t g_blank_graphic = 0; // Panel layout, in native 1080p menu coordinates. The engine's UpdateScrollState pass positions each on-page row at Y @@ -357,24 +342,18 @@ namespace big::mod_settings static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) static constexpr std::uint32_t rows_per_page = 10; // vanilla ItemsPerPage = 10 - // Action-button rows use the taller Button_Secondary box, which crowds the neighbouring setting rows on the uniform - // row pitch. apply_button_spacing nudges each action button down by this lead and shifts the rows below it by - // lead+trail, giving the buttons vertical breathing room (applied through the engine's own SetLocation, so it is - // drift-free - see apply_button_spacing). + // Action-button rows use the taller Button_Secondary box, so apply_button_spacing adds breathing room around them. static constexpr float button_extra_lead = 14.0f; static constexpr float button_extra_trail = 14.0f; - // Config sections. Both rom.mod_settings.load and Chalk bind a mod's settings under the root "config" section. - // Nested groups are dot-separated child sections (e.g. "config.biome_pool"). + // Both rom.mod_settings.load and Chalk bind a mod's settings under the root "config" section. static const std::string root_section = "config"; // Chalk writes a placeholder entry with this key per section so empty groups persist. Skip it. static constexpr const char* section_empty_key = "..."; - // Approximate visual width budget for the right-column value (freetext + its edit caret), in "width units" where a - // typical medium glyph is 1.0 The menu font is variable-width, so a raw character count looks inconsistent (a run of - // 'W' is far wider than a run of 'i') budgeting by summed glyph weight keeps the shown value a consistent WIDTH so it - // does not run left into the key label. + // Approximate visual width budget for the right-column value. The menu font is variable-width, so budget by summed + // glyph weight instead of raw character count. static constexpr float value_display_max_width = 30.0f; // Edit-cursor blink half-period (ms): the "|" shows for this long, then hides. @@ -397,8 +376,7 @@ namespace big::mod_settings std::string stem; // owning mod's config-file stem std::string setting_key; // config entry key (setting rows only) - // The bound config entry (setting rows only) valid for the config file's lifetime, which spans the whole menu - // session. + // The bound config entry, valid for the config file's lifetime. toml_v2::config_file::config_entry_base* entry = nullptr; bool disabled = false; // greyed & non-interactable (mod disabled) @@ -411,35 +389,28 @@ namespace big::mod_settings // last-drawn state so the first click still works. Only meaningful for an interactive virtual bool row. bool toggle_value = false; - // Author-provided description shown at the bottom of the screen while this row is highlighted (setting rows - // only empty for navigation rows). + // Author-provided description shown while this row is highlighted. std::string description; - // Right-column value display for a non-bool setting row (paired with `component`, the left-column key). Not in - // mOptions positioned to follow `component` each frame. + // Right-column value display for a non-bool setting row, positioned to follow `component` each frame. GUIComponent* value_component = nullptr; - // Bounded number setting (metadata has both min and max). Rendered as a native slider (drag bar) spanning - // [stepper_min, stepper_max] and snapped to stepper_step. + // Bounded number setting, rendered as a native slider and snapped to stepper_step. bool is_slider = false; bool is_stepper = false; double stepper_min = 0.0; double stepper_max = 0.0; double stepper_step = 1.0; - // Number-display options (slider value text): is_percentage shows a 0..1 value as 0..100 and appends "%" - // show_as_percentage only appends "%". + // Number-display options for slider value text. bool show_as_percentage = false; bool is_percentage = false; - // Enum cycler (metadata has `values`). Rendered as a native number box over the index 0..labels-1 whose value - // text is overridden to the label (like the game's own enum options) `enum_values` are the serialized config - // values, `enum_labels` the parallel display strings both indexed by the box's current integer value. + // Enum cycler, rendered as a native number box whose value text is overridden to the label. bool is_enum = false; std::vector enum_values; std::vector enum_labels; - // Group rows (RowKind::group) only: the child config section this row drills into. std::string target_section; // The entry's REAL config section (for virtual-row Lua I/O: get/set/text). A `group` override can place a row on a @@ -449,8 +420,7 @@ namespace big::mod_settings static std::vector g_rows; - // Set when a restart-required setting is changed this menu session (e.g. toggling the "enabled" switch of an - // sjson-backed mod). On options-menu close we warn + close the game. + // Set when a restart-required setting is changed this menu session. On options-menu close we warn and close the game. static bool g_restart_required = false; // The restart-causing changes this session, keyed by "\0
\0" so re-editing the same setting @@ -458,24 +428,19 @@ namespace big::mod_settings // popup, e.g. "MyMod: Enabled (on)". static std::map g_restart_changes; - // Baseline serialized value (as of this menu session's open) for each restart-required setting that was touched, - // keyed identically to g_restart_changes. Used to drop a setting from the restart list when it is changed back to - // its baseline (no net change -> no restart needed). + // Baseline value for each touched restart-required setting, used to drop changes that were reverted. static std::map g_restart_baselines; - // The native restart message box's (only) button clicking it closes the game (restart). + // The native restart message box's only button. Clicking it closes the game. static GUIComponent* g_restart_confirm_button = nullptr; - // The restart message box itself (owner of g_restart_confirm_button). A genuine restart button's owner is this dialog - // any rebuilt row's owner is the options screen, so it will not match. + // The restart message box itself, used to distinguish its button from rebuilt option rows. static void* g_restart_dialog = nullptr; // True once the restart prompt has been shown this menu session (so closing again proceeds). static bool g_restart_prompt_shown = false; - // Which view the Mods panel is currently showing, plus a deferred navigation request that a click sets and the - // Update hook applies at a safe point (outside input/click iteration, where mutating the component vectors is - // safe). + // Current Mods panel view plus a deferred navigation request applied from the Update hook. enum class View { mod_list, @@ -502,9 +467,7 @@ namespace big::mod_settings std::string config_section; // real config section, to distinguish same-named keys grouped onto one page }; - // The clicked row to hold as hovered/selected across a click-triggered instant rebuild, captured in the OnClicked - // hook. A rebuild frees every component and the native hover pass re-resolves the cursor a frame later, so this is - // re-asserted for a few frames (see reassert_keep_active_row) to steady the prompt, description and highlight. + // The clicked row to hold as hovered/selected across a click-triggered instant rebuild. static RowIdentity g_keep_active_row; // Frames to re-assert the clicked row as hovered/selected after a click-triggered instant rebuild. The native hover @@ -513,13 +476,10 @@ namespace big::mod_settings static constexpr int keep_active_frame_count = 3; static int g_keep_active_frames = 0; - // Seconds of input quiet after a numeric setting (slider/number-box) changes before the view is rebuilt to - // re-evaluate its dynamic (Lua-function) rows - e.g. an apply button's dynamic `disabled`. + // Seconds of input quiet after a numeric setting changes before dynamic rows are rebuilt. static constexpr float dynamic_refresh_settle_seconds = 0.15f; - // Time left on that debounce (0 = nothing pending). A slider fires its change hook every frame while dragged and a - // rebuild frees the dragged row, so we wait for a short quiet gap and rebuild once the drag settles. Re-armed on - // every change ticked down in the Update hook. + // Time left on that debounce. A slider fires every frame while dragged, so rebuild only after a quiet gap. static float g_dynamic_refresh_settle = 0.0f; // Navigation restore stack: one entry per drill-in level. Each records the parent view's scroll offset and which row @@ -536,8 +496,7 @@ namespace big::mod_settings static NavRestore g_pending_restore; static bool g_has_pending_restore = false; - // Freetext edit state (number/string settings). A click enters edit mode typed input is captured in the window - // procedure and applied on the game thread in the Update hook. + // Freetext edit state. Typed input is captured in the window procedure and applied on the game thread. static bool g_editing = false; static GUIComponent* g_edit_component = nullptr; static toml_v2::config_file::config_entry_base* g_edit_entry = nullptr; @@ -548,10 +507,7 @@ namespace big::mod_settings static bool g_edit_confirm = false; static bool g_edit_cancel = false; - // Turns a config-file stem ("AuthorName-ModName") into a display name: drops the author (up to the first '-') and runs - // the mod name through key_to_display, so '_' becomes a space and camelCase/PascalCase word boundaries are split - the - // same friendly-name logic used for setting keys "SGG_Modding-Chalk" -> "Chalk". "NikkelM-Zagreus_Journey" -> "Zagreus - // Journey" "zerp-DreamDiveTweaks" -> "Dream Dive Tweaks". + // Turns a config-file stem ("AuthorName-ModName") into a display name using the setting-key friendly-name logic. static std::string key_to_display(const std::string& key); // shared friendly-name logic, defined below #pragma endregion @@ -584,8 +540,7 @@ namespace big::mod_settings return {}; } - // Shown in the description box in place of the mod description when a mod opted out of the in-game settings menu - // (rom.mod_settings.opt_out()), explaining why its row is greyed and where to configure it instead. + // Description-box note for a mod that opted out of the in-game settings menu. static std::string opt_out_note() { return "This mod opted out of the in-game settings menu. See the mod's own description for how " @@ -594,8 +549,7 @@ namespace big::mod_settings static std::string resolve_localized(const localized_text& t); // defined below - // The description shown for an opted-out mod's greyed row: the author's own opt_out(description) if they supplied - // one (resolved to the current game language), otherwise the generic opt_out_note(). + // Description for an opted-out mod's greyed row, preferring the author's localized note. static std::string opt_out_description(const std::string& stem) { const std::string custom = resolve_localized(mod_opt_out_description(stem)); @@ -648,8 +602,7 @@ namespace big::mod_settings return a.size() < b.size() ? -1 : 1; } - // --- Text metrics + caret helpers (byte indices into a string UTF-8 aware) --- Approximate width of a single byte in - // the value font, in the same units as value_display_max_width (medium glyph. + // Approximate width of a UTF-8 byte in the value font, in the same units as value_display_max_width. static float glyph_weight(unsigned char c) { if (c >= 0xC0) @@ -694,7 +647,6 @@ namespace big::mod_settings } } - // Summed approximate visual width of a string (see glyph_weight). static float measure_width(const std::string& s) { float w = 0.0f; @@ -713,7 +665,7 @@ namespace big::mod_settings return (u >= '0' && u <= '9') || (u >= 'A' && u <= 'Z') || (u >= 'a' && u <= 'z') || u == '_' || u >= 0x80; } - // Caret one codepoint to the left (skips UTF-8 continuation bytes so a multibyte char moves as a unit). + // Caret one codepoint to the left, skipping UTF-8 continuation bytes. static std::size_t caret_prev(const std::string& s, std::size_t pos) { if (pos == 0) @@ -728,7 +680,6 @@ namespace big::mod_settings return pos; } - // Caret one codepoint to the right. static std::size_t caret_next(const std::string& s, std::size_t pos) { if (pos >= s.size()) @@ -743,8 +694,7 @@ namespace big::mod_settings return pos; } - // Caret to the start of the current/previous word (Ctrl+Left): skip any non-word bytes to the left, then the run of - // word bytes. + // Caret to the start of the current/previous word for Ctrl+Left. static std::size_t caret_prev_word(const std::string& s, std::size_t pos) { while (pos > 0 && !is_word_byte(s[pos - 1])) @@ -758,8 +708,7 @@ namespace big::mod_settings return pos; } - // Caret to the start of the next word (Ctrl+Right): skip the current run of word bytes, then the following non-word - // bytes. + // Caret to the start of the next word for Ctrl+Right. static std::size_t caret_next_word(const std::string& s, std::size_t pos) { const std::size_t n = s.size(); @@ -774,8 +723,7 @@ namespace big::mod_settings return pos; } - // Caps an over-wide value string for the right-aligned value column so it does not run left into the option's key - // label. Fits by summed glyph WIDTH, not character count, so wide/narrow text shows a consistent visual width. + // Caps an over-wide value string by summed glyph width so it does not run left into the option's key label. static std::string truncate_value(const std::string& text) { if (measure_width(text) <= value_display_max_width) @@ -815,10 +763,7 @@ namespace big::mod_settings button->m_hidden = false; button->m_is_useable = true; - // Point the button's localization id at "Mods" so the engine's own label pipeline resolves it. - // GUIComponentButton::UseDefaultText re-derive "Mods" natively - including after a language change, which re-runs - // that derivation and would otherwise revert the tab to "Editor" "Mods" has no text-data entry, so the lookup misses - // and the engine renders the raw key ("Mods") verbatim in every language. + // Point the button's localization id at "Mods" so language changes keep rendering the raw key instead of "Editor". if (g_hash_lookup) { HashGuid id{}; @@ -826,9 +771,7 @@ namespace big::mod_settings *reinterpret_cast(reinterpret_cast(button) + sgg::gui_component_button_display_name_id_offset) = id.m_id; } - // Apply the label now for the initial display: the original constructor already rendered the native "Editor" - // text from the native Editor id, and UseDefaultText only re-derives on the next localization pass. Subsequent - // language changes are handled by the id above, not here. + // Apply the label now because UseDefaultText only re-derives on the next localization pass. if (g_set_label) { g_set_label(button, "Mods"); @@ -839,7 +782,6 @@ namespace big::mod_settings #pragma region Native row construction and styling - // Writes an in-place EASTL short-string (SSO, up to 22 chars) into a component field. static void set_sso_string(void* field, const char* text) { char* bytes = static_cast(field); @@ -895,9 +837,7 @@ namespace big::mod_settings return row; } - // Links a finished row into the drawn/hit-tested (mComponents) and paged (mOptions) vectors, sets its X, and starts - // it transparent UpdateScrollState only fades in and repositions on-page rows, so off-page rows must start - // invisible to avoid flashing stacked at the top. + // Links a finished row into the drawn and paged vectors. Off-page rows start transparent to avoid flashing at the top. static void finalize_row(MiscSettingsScreen* screen, GUIComponent* row, bool in_options = true) { GUIComponent* value = row; @@ -911,12 +851,10 @@ namespace big::mod_settings row->m_location_x = row_location_x; row->m_fade_opacity = 0.0f; - // Uniform opacity ease rate so every row type fades at the same native speed (see row_fade_speed). *reinterpret_cast(reinterpret_cast(row) + component_def_offset + def_fade_speed) = row_fade_speed; } - // Shows the on or off toggle graphic for a toggle row. The OptionToggleButton template stores both graphic hashes - // in the row's def (mGraphic = on, mAlternateGraphic = off) pick one and set it as the drawn texture. + // Shows the on or off toggle graphic from the OptionToggleButton template. static void set_toggle_graphic(GUIComponent* row, bool is_on) { if (!g_set_normal_texture) @@ -929,10 +867,7 @@ namespace big::mod_settings g_set_normal_texture(row, is_on ? on_hash : off_hash, false); } - // Reproduces the vanilla toggle click sound. A native toggle plays its cue from ToggleOptionValueChanged, which our - // toggle path replaces, and the base OnClicked only plays mPressSound (unset in the toggle template) - so a toggle - // would be silent. Copying the cue for the value the click will produce into mPressSound lets the native audio path - // play it with the correct swap handling. + // Reproduces the vanilla toggle click sound by copying the cue for the value the click will produce into mPressSound. static void stage_toggle_press_sound(GUIComponent* row, bool new_value) { char* def = reinterpret_cast(row) + component_def_offset; @@ -954,8 +889,7 @@ namespace big::mod_settings *reinterpret_cast(def + def_sel_text_blue) = grey; } - // Greys a child GUIComponentTextBox (a slider/num-box label or value box) by giving it a disabled text colour and - // flagging it to use that colour. Grey text colour matches set_def_text_grey so every disabled row reads the same. + // Greys a child GUIComponentTextBox with the same colour used by set_def_text_grey. static void grey_text_box(void* text_box) { if (!text_box) @@ -969,9 +903,7 @@ namespace big::mod_settings *reinterpret_cast(b + textbox_disabled_text_alpha) = 1.0f; *reinterpret_cast(b + textbox_use_disabled_color_off) = true; - // Also grey the normal and selected text colours (red/green/blue triples). A still-selectable greyed row keeps - // mIsUseable=1, so Slider/NumBox Draw clears mUseDisabledTextColor and the label falls back to the normal colour (and - // the selected colour on hover) - greying both keeps it greyed in every state, with no hover highlight. + // Still-selectable greyed rows keep mIsUseable=1, so grey the normal and selected colours too. for (const std::size_t base : {textbox_text_red, textbox_selected_text_red}) { *reinterpret_cast(b + base + 0x0) = disabled_text_grey; @@ -980,9 +912,7 @@ namespace big::mod_settings } } - // Dims a GUIComponentImage (a slider's bar backing/fill) to the disabled grey. Image::Draw tints from mColor each - // frame and neither Slider::Draw nor Slider::Update recolour the bar, so writing mColor (plus mColorTarget so the - // per-frame lerp does not pull it back) sticks. + // Dims a GUIComponentImage to the disabled grey. mColorTarget is written too so the per-frame lerp does not undo it. static void grey_image(void* image) { if (!image) @@ -994,10 +924,7 @@ namespace big::mod_settings *reinterpret_cast(b + image_color_target_offset) = disabled_graphic_grey; } - // Greys a disabled toggle's on/off ring so it reads greyed from frame one. The ring is a bare texture that Draw - // paints with mButtonColor, which starts black and only eases toward mColorTarget on hover - so an untouched - // disabled toggle would show black. Setting both to the grey (Update sees them equal and never eases) plus - // mSelectedRed < 0 to skip the selected-colour override keeps it greyed at rest and through hover. + // Greys a disabled toggle's bare on/off texture from frame one and disables the selected-colour override. static void grey_toggle_graphic(GUIComponent* row) { char* b = reinterpret_cast(row); @@ -1006,10 +933,7 @@ namespace big::mod_settings *reinterpret_cast(b + component_def_offset + def_sel_red) = -1.0f; } - // Sets a row's normal text colour to the native settings-option grey (0.55) used by the game's own. - // OptionToggleButton/OptionNumBox rows, so plain-text (key/value) rows built on the CategoryOptionsButton template - // (whose own text is a darker 0.35) match the toggle rows instead of reading as brighter full white. Must run before - // SetupComponent to reach the text box. + // Sets a row's normal text colour to the native settings-option grey used by OptionToggleButton/OptionNumBox rows. static void set_def_text_normal(GUIComponent* row, bool also_selected = false) { char* def = reinterpret_cast(row) + component_def_offset; @@ -1025,8 +949,7 @@ namespace big::mod_settings } } - // A plain left-justified text row (mod names, Back, and non-toggle settings). Disabled rows are greyed. By default - // they are also hard-disabled (non-selectable). + // A plain left-justified text row. Disabled rows are greyed and hard-disabled by default. static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true, bool no_hover_highlight = false) { auto* row = create_button(screen); @@ -1095,10 +1018,7 @@ namespace big::mod_settings return row; } - // A toggle row (boolean setting): a left-justified label plus the native on/off toggle switch graphic on the right. - // The OptionToggleButton template already supplies the toggle graphic, left-justified text and text area we only - // realign it to our row grid (mY/mSpacing, read directly by UpdateScrollState) and choose the on/off graphic. Disabled - // rows grey their ring (grey_toggle_graphic) and label from frame one. + // A toggle row: a left-justified label plus the native on/off toggle graphic. static GUIComponent* make_toggle_row(MiscSettingsScreen* screen, const char* label, bool is_on, bool disabled = false, bool block_input = true) { auto* row = create_button(screen); @@ -1115,9 +1035,7 @@ namespace big::mod_settings *reinterpret_cast(def + def_y) = row_base_y; *reinterpret_cast(def + def_spacing) = row_pitch; - // Greying needs a SetupComponent pass to reach the text box and button colour. The toggle graphic is re-chosen - // afterwards so the pass does not revert it. The button tint is switched from additive to a multiplicative dim - // so the toggle graphic reads as greyed rather than full brightness. + // Greying needs a SetupComponent pass to reach the text box and button colour. if (disabled) { set_def_text_grey(row); @@ -1140,14 +1058,11 @@ namespace big::mod_settings if (disabled) { - // Grey the on/off ring from frame one (it has no colour of its own and would otherwise stay black until a - // hover eases it grey - see grey_toggle_graphic). grey_toggle_graphic(reinterpret_cast(row)); if (block_input && g_disable) { - // Whole-mod-off toggle: also drop mIsUseable via Disable so nav and hover skip the row. A - // context/author-disabled toggle keeps mIsUseable so it stays mouse-hoverable for its note. + // Whole-mod-off toggles drop mIsUseable so nav and hover skip the row. g_disable(row); } } @@ -1156,9 +1071,7 @@ namespace big::mod_settings return row; } - // A centered native button row (for actions like Apply/Reset), using the CategoryOptionsButton template unchanged so - // it keeps its Button_Secondary box graphic and centered label - visually distinct from the plain-text setting rows. - // Disabled rows are greyed by default they are also hard-disabled (non-selectable). + // A centered native button row for actions like Apply/Reset, visually distinct from plain-text setting rows. static void install_wide_button_nav_rect(GUIComponent* row); // defined below (near row_bounded_area) static GUIComponent* make_button_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) @@ -1173,8 +1086,7 @@ namespace big::mod_settings set_sso_string(row_bytes + gui_component_name_offset, "CategoryOptionsButton"); g_apply_data(reinterpret_cast(screen), row); - // Stretch the box only enough to fit a label wider than the native box (see button_label_*), so short labels - // keep the clean native box. Drawn box width = native * mScale * box_scale_x. + // Stretch the box only enough to fit a label wider than the native box. const float box_scale_x = std::max(1.0f, (measure_width(label) + button_label_padding) / button_label_capacity); constexpr float button_scale = 0.8f; @@ -1190,10 +1102,7 @@ namespace big::mod_settings *reinterpret_cast(def + def_width) = button_graphic_native_width; *reinterpret_cast(def + def_height) = 58.0f; - // Momentary selection: the CategoryOptionsButton template keeps a button selected (its highlight lit) after a - // mouse-off - correct for the category tabs, but an action button should not stay lit like a selected tab once - // clicked. mDeselectOnMouseOff makes the highlight clear when the cursor leaves (the highlight still shows while - // hovered), so the action button reads as momentary. + // mDeselectOnMouseOff makes action buttons read as momentary instead of staying lit like category tabs. *reinterpret_cast(def + def_deselect_on_mouse_off) = true; if (disabled) @@ -1219,12 +1128,9 @@ namespace big::mod_settings g_set_label(row, label); } - // Widen the box graphic to box_scale_x. The box is a single-frame animation reached via mAnim enabling - // mScaleModifierOnlyX makes GUIComponentAnimation::Draw honour the anim's own def mScaleX (horizontal-only), which - // the button otherwise leaves at a uniform scale. + // mScaleModifierOnlyX makes GUIComponentAnimation::Draw honour the anim's own horizontal mScaleX. if (box_scale_x > 1.0f) { - // component_def_scale_* offsets are from the component base *reinterpret_cast(row_bytes + component_def_scale_x_offset) = box_scale_x; *reinterpret_cast(row_bytes + component_def_scale_y_offset) = 1.0f; @@ -1232,9 +1138,8 @@ namespace big::mod_settings { char* anim_bytes = reinterpret_cast(anim); *reinterpret_cast(anim_bytes + anim_scale_modifier_only_x_offset) = true; - // component_def_scale_* offsets are from the component base - *reinterpret_cast(anim_bytes + component_def_scale_x_offset) = box_scale_x; - *reinterpret_cast(anim_bytes + component_def_scale_y_offset) = 1.0f; + *reinterpret_cast(anim_bytes + component_def_scale_x_offset) = box_scale_x; + *reinterpret_cast(anim_bytes + component_def_scale_y_offset) = 1.0f; } } @@ -1243,9 +1148,7 @@ namespace big::mod_settings g_disable(row); } - // The CategoryOptionsButton template is shared with the top category tabs (paged by bumpers, not the vertical - // nav), so it leaves mFreeFormSelectable unset and SearchInDirection skips it. Opt an enabled action button in, - // and give it a wide nav rect too, since its native GetArea is a narrow rect at the centred label. + // CategoryOptionsButton leaves mFreeFormSelectable unset, so opt enabled action buttons into vertical nav. if (!disabled) { *reinterpret_cast(row_bytes + component_free_form_selectable_offset) = true; @@ -1254,15 +1157,11 @@ namespace big::mod_settings finalize_row(screen, row); - // Centre the button in the content pane (finalize_row anchors rows at the right-hand option column, which would - // put the button over the scrollbar). row->m_location_x = button_center_x; return row; } - // A right-justified, non-interactive value label for the right column of a key/value setting row (paired with a - // left-column key row). It shares the key's component X anchor but uses RIGHT justification, so the value sits in the - // right column while the key stays left. + // A right-justified, non-interactive value label paired with a left-column key row. static GUIComponent* make_value_display(MiscSettingsScreen* screen, const char* text, bool disabled) { auto* row = create_button(screen); @@ -1324,14 +1223,12 @@ namespace big::mod_settings return row; } - // True for a finite whole number (used to pick integer vs float num-box display/stepping). static bool is_whole(double v) { return std::isfinite(v) && v == std::floor(v); } - // Overrides a num-box's centered value text (its mValueTextBox) with an enum option label. The label is escaped so - // paths/brackets in the option text render verbatim (see escape_markup). + // Overrides a num-box's centered value text with an escaped enum option label. static void set_numbox_value_text(GUIComponent* numbox, const char* text) { if (!g_show_text || !numbox) @@ -1344,9 +1241,7 @@ namespace big::mod_settings } } - // A native num-box stepper row, as used by the game's own FPS-limit and graphics-quality options. The game's factory - // allocates it and builds all five sub-components, which the row teardown frees with it. Value edits are persisted - // by the SetNumberValue hook. Not a GUIComponentButton, so it never routes through the OnClicked hook. + // A native num-box stepper row, as used by the game's own FPS-limit and graphics-quality options. static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled, const std::vector* value_labels = nullptr, bool block_input = true) { if (!g_numbox_factory || !g_numbox_set_range || !g_numbox_set_value || !g_apply_data || !g_show_text) @@ -1362,8 +1257,7 @@ namespace big::mod_settings } char* nb_bytes = reinterpret_cast(nb); - // Name the box and its sub-components so ApplyDataToComponent applies the matching sjson templates (its - // virtual ApplyDataToName routes each def by the sub-component's mName). + // Name the box and its sub-components so ApplyDataToComponent applies the matching sjson templates. set_sso_string(nb_bytes + gui_component_name_offset, "OptionNumBox"); if (void* value_tb = *reinterpret_cast(nb_bytes + numbox_value_text_offset)) { @@ -1399,16 +1293,14 @@ namespace big::mod_settings *reinterpret_cast(def + def_spacing) = row_pitch; } - // The label lives in the num-box's own left text box (raw text, like our other rows). if (void* label_tb = *reinterpret_cast(nb_bytes + numbox_label_text_offset)) { g_show_text(label_tb, label); } - // Paint the starting value notify=false so the SetNumberValue hook does not persist it. + // notify = false, or the SetNumberValue hook would persist this initial paint as a user edit. g_numbox_set_value(nb, static_cast(initial), false); - // Enum cycler: replace the raw index. The box just painted with the option's label. if (value_labels && !value_labels->empty()) { int idx = static_cast(initial); @@ -1425,9 +1317,7 @@ namespace big::mod_settings if (disabled) { - // mDisableInput is the num-box's own input gate, blocking arrow-clicks and keyboard stepping alike; - // mIsUseable does NOT gate num-box input. When block_input is set (the whole-mod-off case) clear mIsUseable - // too so nav and hover skip the row entirely. + // mDisableInput gates num-box input. mIsUseable only controls nav and hover. *reinterpret_cast(nb_bytes + numbox_disable_input_offset) = true; grey_text_box(*reinterpret_cast(nb_bytes + numbox_label_text_offset)); grey_text_box(*reinterpret_cast(nb_bytes + numbox_value_text_offset)); @@ -1453,7 +1343,6 @@ namespace big::mod_settings double shown = is_pct ? value * 100.0 : value; const double disp_step = is_pct ? step * 100.0 : step; - // Decimal places implied by the display step (0.01 -> 2, 1 -> 0), capped for sanity. int decimals = 0; if (disp_step > 0.0) { @@ -1480,8 +1369,7 @@ namespace big::mod_settings return out; } - // Sets the slider's right-hand value text (mValueTextBox). The native drag handler rewrites this to a percentage on - // every change, so we re-apply the setting's real value after each user edit. + // Sets the slider's right-hand value text after native dragging rewrites it to a percentage. static void set_slider_value_text(GUIComponent* slider, const char* text) { if (!g_show_text || !slider) @@ -1520,9 +1408,7 @@ namespace big::mod_settings return reinterpret_cast(dst); } - // Installs the patched button vtable (see build_row_area_vtable) on `row`, building the copy lazily from the row's - // current native vtable on first use. A centre-column action button's native GetArea is a narrow rect at the button - // centre that the vertical nav ray never crosses. The wide rect makes it reachable like any setting row. + // Installs the patched button vtable so centre-column action buttons are reachable by vertical nav. static void install_wide_button_nav_rect(GUIComponent* row) { if (!g_button_vtable_patched) @@ -1556,8 +1442,7 @@ namespace big::mod_settings g_gui_component_ctor(s, 0); *reinterpret_cast(s) = g_slider_vtable_patched ? g_slider_vtable_patched : g_slider_vtable; - // Defaults does not initialise mOnValueChanged or mValueTextBox, so zero them (the block is freshly malloc'd) - // before Defaults runs and before anything reads them. + // Defaults does not initialise mOnValueChanged or mValueTextBox, so zero them before Defaults runs. std::memset(s + slider_on_changed_offset, 0, 3 * sizeof(void*)); *reinterpret_cast(s + slider_label_offset) = nullptr; *reinterpret_cast(s + slider_value_text_offset) = nullptr; @@ -1600,7 +1485,6 @@ namespace big::mod_settings g_apply_data(reinterpret_cast(screen), reinterpret_cast(s)); - // Override the template's row grid (Y=300, Spacing=45) so the bar lines up with the other rows. { char* def = s + component_def_offset; *reinterpret_cast(def + def_y) = row_base_y; @@ -1612,8 +1496,7 @@ namespace big::mod_settings g_show_text(label_tb, label); } - // Paint the starting value: map [min,max] -> 0..1 and set the fraction without notifying (so the SetFraction - // hook does not treat it as a user edit), then show the real value (not a percentage). + // Paint the starting value without notifying so the SetFraction hook does not treat it as a user edit. const double range = max_v - min_v; const float frac = (range > 0.0) ? static_cast((initial - min_v) / range) : 0.0f; g_slider_set_fraction(s, frac, false); @@ -1622,9 +1505,7 @@ namespace big::mod_settings if (disabled) { - // Grey every visible part explicitly: the value box and both bar images, plus the label (which Slider::Draw would - // otherwise only grey off mIsUseable). Mouse-drag is separately blocked in the HandleInput hook (the native drag - // path ignores mIsUseable). + // Grey every visible part explicitly. The native drag path ignores mIsUseable, so HandleInput blocks it separately. auto* sc = reinterpret_cast(s); if (block_input) { @@ -1645,9 +1526,7 @@ namespace big::mod_settings #pragma region Row teardown and mod list - // Removes the first pointer equal to `value` from an eastl vector by shifting the tail down in place - the same - // unlink the engine's DoShowCategory performs. No-op if not present. The backing storage is left owned by the - // vector. + // Removes the first pointer equal to `value` from an eastl vector by shifting the tail down in place. static void vector_erase(sgg::eastl_vector& vec, GUIComponent* value) { for (GUIComponent** it = vec.m_begin; it != vec.m_end; ++it) @@ -1661,10 +1540,7 @@ namespace big::mod_settings } } - // Tears down every custom row we currently own: clears any screen pointer that still references a row (so the engine - // cannot dereference it after free), unlinks it from the drawn/hit-tested mComponents and the paged mOptions, then - // destroys and frees it. Our rows are not registered in the reflection helper, so the engine never frees them and - // never double-frees here. + // Tears down every custom row we currently own. Our rows are not registered in the reflection helper. static void destroy_rows(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); @@ -1704,9 +1580,7 @@ namespace big::mod_settings if (owns_subcomponents) { - // The num-box and slider are not GUIComponentButtons destruct through the component's own vtable so its owned - // sub-components (num-box: box/label/value/arrows slider: background/fill/label/value) are freed too flags=0 - // destructs without the final operator delete, so we still free the block ourselves (see game_free). + // Num-boxes and sliders destruct through their own vtables so their owned sub-components are freed too. void** vtbl = *reinterpret_cast(comp); auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset / sizeof(void*)]); dtor(comp, 0); @@ -1727,7 +1601,7 @@ namespace big::mod_settings g_rows.clear(); } - // Level 1: one row per installed mod (config-file stem), friendly display name, sorted. + // Level 1: one row per installed mod, sorted by friendly display name. static void build_mod_list(MiscSettingsScreen* screen) { std::vector stems; @@ -1764,9 +1638,7 @@ namespace big::mod_settings for (const auto& [display, stem] : mods) { - // A mod that called rom.mod_settings.opt_out() is still listed (dropping it would look like a missing mod), but its - // row is greyed and cannot be opened, and its description is a note pointing back to the mod's own description. The - // drilldown is blocked by the disabled flag in the click handler. + // Opted-out mods stay listed so they do not look missing, but their rows are greyed and cannot be opened. const bool opted_out = mod_opted_out(stem); if (auto* row = make_text_row(screen, escape_markup(display).c_str(), opted_out, /*block_input*/ false)) { @@ -1782,8 +1654,7 @@ namespace big::mod_settings #pragma region Value formatting, freetext editing, and commit - // Turns an identifier into a friendly display string: underscores become spaces, and camelCase/PascalCase word - // boundaries are split ("z_ThisConfigKey" -> "z. The first letter is capitalized ("enabled" -> "Enabled"). + // Turns an identifier into a friendly display string: underscores become spaces and word boundaries are split. static std::string key_to_display(const std::string& key) { const auto is_upper = [](char c) @@ -1818,8 +1689,6 @@ namespace big::mod_settings out.push_back(c); } - // Capitalize the first letter so a key/mod name with no author display_name still reads as a proper title - // ("enabled" -> "Enabled"). for (char& c : out) { if (c != ' ') @@ -1848,15 +1717,12 @@ namespace big::mod_settings const float caret_w = 0.6f; // reserve a little for the caret glyph const float ellipsis_w = measure_width("..."); - // Whole buffer (plus caret) fits: no truncation. if (measure_width(buf) + caret_w <= value_display_max_width) { return escape_markup(buf.substr(0, cursor)) + caret + escape_markup(buf.substr(cursor)); } - // Grow a window [start, end) outward from the caret, one codepoint at a time, alternating left then right, - // while it still fits the budget (accounting for the ellipses each side will need). Left grows first each round - // so a right-aligned field shows preceding context. + // Grow a width-budgeted window outward from the caret, left first so a right-aligned field shows preceding context. std::size_t start = cursor; std::size_t end = cursor; float used = caret_w; @@ -1906,9 +1772,7 @@ namespace big::mod_settings return out; } - // Accepts a character into a numeric edit buffer only if the result stays a plausible numeric literal: an optional - // leading sign (only at the front), digits, at most one decimal point `cursor` is where the character would be - // inserted. + // Accepts a character into a numeric edit buffer only if the result stays a plausible numeric literal. static bool numeric_char_ok(const std::string& buffer, std::size_t cursor, char c) { if (c >= '0' && c <= '9') @@ -1917,7 +1781,6 @@ namespace big::mod_settings } if (c == '-' || c == '+') { - // A sign is valid only inserted at the very front, and only if no sign is there already. return cursor == 0 && (buffer.empty() || (buffer.front() != '-' && buffer.front() != '+')); } if (c == '.') @@ -2015,7 +1878,6 @@ namespace big::mod_settings registered = true; } - // Enters freetext edit on `value_component` (the row's right-column value display). static void enter_edit_mode(GUIComponent* value_component, toml_v2::config_file::config_entry_base* entry) { ensure_wndproc_registered(); @@ -2040,15 +1902,12 @@ namespace big::mod_settings g_edit_cancel = false; } - // Composite key ("\0
\0") uniquely identifying a config entry across mods. static std::string restart_change_key(toml_v2::config_file::config_entry_base* entry, const std::string& stem) { return stem + '\0' + entry->m_definition.m_section + '\0' + entry->m_definition.m_key; } - // Captures a restart-required setting's baseline (its value as of this menu session's open) BEFORE It is first - // modified, so a later change back to this value can be recognised as "no net change". Called just before the value - // is written. No-op for non-restart-required settings and after the first capture for a given setting. + // Captures a restart-required setting's baseline before its first modification so a later revert clears it. static void capture_restart_baseline(toml_v2::config_file::config_entry_base* entry) { if (!entry || !entry->m_config_file) @@ -2071,9 +1930,7 @@ namespace big::mod_settings return g_config_language ? std::string(g_config_language) : std::string(); } - // Resolves a localized string to the current game language: the entry for the current language code, then English, - // then the unlocalized value (empty key), then any entry. Resolution happens here (render time), so re-entering the - // tab after a language change picks up the new language. + // Resolves a localized string to the current language code, then English, then the unlocalized value, then any entry. static std::string resolve_localized(const localized_text& t) { if (t.empty()) @@ -2099,9 +1956,7 @@ namespace big::mod_settings return t.begin()->second; } - // True if the current settings view has any row with a dynamic (Lua-function) description field. Recomputed each - // build (see build_panel). Consulted when a bool toggle changes so the panel is rebuilt in place to re-evaluate - // dynamic disabled/ranges/options against the new value. + // True if the current settings view has any row with a dynamic Lua-function field. static bool g_view_has_dynamic = false; // A setting's metadata with any dynamic (Lua-function) description fields evaluated against the current game state. @@ -2111,9 +1966,7 @@ namespace big::mod_settings auto meta = get_setting_metadata(stem, section, key); if (!meta) { - // Not a config-backed setting registered at load (e.g. a virtual row, whose metadata lives only in the Lua - // descs registry). Resolve it straight from there. Returns nullopt when there is no rich description there - // either (a Chalk setting, or a plain-string desc), exactly as before. + // Virtual-row metadata lives only in the Lua descs registry, so resolve it straight from there. return resolve_setting_metadata(stem, section, key); } if (meta->has_dynamic) @@ -2127,8 +1980,7 @@ namespace big::mod_settings return meta; } - // The friendly display name for a setting: the author's `display_name` override when provided, otherwise the - // prettified key. Mirrors how the setting rows are labelled. + // The friendly display name for a setting: the author's override when provided, otherwise the prettified key. static std::string setting_display_name(const std::string& stem, const std::string& section, const std::string& key) { const auto meta = resolved_metadata(stem, section, key); @@ -2161,12 +2013,10 @@ namespace big::mod_settings const auto baseline = g_restart_baselines.find(key); if (baseline != g_restart_baselines.end() && entry->get_serialized_value() == baseline->second) { - // Matches the session baseline, so it has no net restart requirement g_restart_changes.erase(key); } else { - // Stored plain word-wrapped with regular spaces for the dialog in build_restart_message. const std::string line = display_name_from_stem(stem) + ": " + setting_display_name(stem, entry->m_definition.m_section, entry->m_definition.m_key) + " (" + new_value_display + ")"; g_restart_changes[key] = line; @@ -2183,9 +2033,7 @@ namespace big::mod_settings return !row->config_section.empty() ? row->config_section : g_view_section; } - // Commit helpers for an edited row: they write the config entry (with restart-required tracking) when the row is - // config-backed, or call the interactive virtual row's Lua set() callback when it is virtual. Each returns true if - // the value actually changed, and arms the dynamic live-refresh so dependent rows re-evaluate. + // Commit helpers for edited rows, with restart-required tracking and dynamic live-refresh arming. static bool commit_row_bool(PanelRow* row, bool v) { bool changed = false; @@ -2250,8 +2098,7 @@ namespace big::mod_settings return changed; } - // `serialized` is the config-serialized value (also the enum option's stored value). A config entry parses it back. - // A virtual set() receives it as a string (virtual enum options are matched/passed as strings). + // `serialized` is the config-serialized value. Virtual set() receives it as a string. static bool commit_row_serialized(PanelRow* row, const std::string& serialized, const std::string& display) { bool changed = false; @@ -2305,12 +2152,9 @@ namespace big::mod_settings { if (g_edit_entry) { - // Capture the session baseline before the first write so a later revert is recognised as "no net - // change". capture_restart_baseline(g_edit_entry); - // set_serialized_value validates (e.g. numbers) and only stores/saves a valid value, so bad input for a - // number simply keeps the previous value. + // Bad numeric input simply keeps the previous value because set_serialized_value validates before saving. g_edit_entry->set_serialized_value(g_edit_buffer); // Clamp/snap a bounded freetext number to the stepper grid: [min, max] and min + k*step. @@ -2348,7 +2192,6 @@ namespace big::mod_settings } } - // If the author declared this setting restart-required, flag/clear the restart. note_change_if_restart_required(g_edit_entry, g_edit_entry->get_serialized_value()); // Reflect the committed value in the right-hand display in place. Do NOT rebuild the panel here: a rebuild frees @@ -2356,8 +2199,7 @@ namespace big::mod_settings // position, so the rows and the scrollbar desync until the next manual scroll. refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); - // Other rows may still key off this value (e.g. an apply button's dynamic `disabled`), so a dynamic - // view re-evaluates its function rows shortly after (see g_dynamic_refresh_settle). + // Other rows may still key off this value, so dynamic views re-evaluate shortly after. if (g_view_has_dynamic) { g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; @@ -2368,8 +2210,7 @@ namespace big::mod_settings } if (g_edit_cancel) { - // Restore the display to the unchanged value (the live caret label was transient) no rebuild, for the same - // scroll-preservation reason as the commit path above. + // Restore the unchanged value without a rebuild, for the same scroll-preservation reason. if (g_edit_entry) { refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); @@ -2380,8 +2221,7 @@ namespace big::mod_settings return false; } - // Live-updates the edited value display (right column) with a movable, blinking caret. Called from Update while - // editing is active g_edit_component is the row's value component. + // Live-updates the edited value display with a movable, blinking caret. static void update_edit_label() { if (g_edit_component && g_set_label) @@ -2402,40 +2242,28 @@ namespace big::mod_settings return big::string::to_lower(key) == "enabled"; } - // True if a config entry carries an author-written description string. Our own loader also writes the description here - // for string/`description`-field descs, and additionally records metadata-only descs in g_described_keys, so the two - // checks together recognize every configDesc form as "described". + // True if a config entry carries an author-written description string. static bool entry_has_description(const toml_v2::config_file::config_entry_base* entry) { return entry && !entry->m_description.m_description.empty(); } - // True when the options screen was opened during gameplay (a save is loaded), false when opened from the main menu. - // Captured from the MiscSettingsScreen constructor's "opened from" argument (see hook_MiscSettingsScreen_ctor). - // Used to grey out context-restricted setting rows. + // True when the options screen was opened during gameplay, false when opened from the main menu. static bool g_opened_in_game = false; - // True when the game global `CurrentHubRoom` is non-nil, i.e. the player is in the hub (the Crossroads) rather than in - // a run. Captured once in the ctor (see hook_MiscSettingsScreen_ctor) via game_is_in_hub() - the context cannot change - // while the pause screen is open. + // True when CurrentHubRoom is non-nil, i.e. the player is in the Crossroads rather than in a run. static bool g_in_hub = false; - // True while a native options screen is open. Set in the ctor, cleared when it closes in ExitScreen. static bool g_options_screen_open = false; - // True while a setting change should notify its mod through an on_change callback: an options screen is currently - // open. Fires for any edit made through our options menu, in the main menu or in a save (so a callback can also - // drive other rows' dynamic min/max/values/disabled), but not from a mod's own config write outside the menu. - // Callbacks must guard live-run access (game.CurrentRun and GameState may be absent in the main menu). + // True while options-menu edits should notify mods through on_change callbacks. bool on_change_callbacks_enabled() { return g_options_screen_open; } - // The MiscSettingsScreen ctor's "opened from" argument is the opening screen (sgg::MenuScreen*): a MainMenuScreen when - // opened from the main menu, a PauseScreen when opened in-game (the only two call sites in the engine). - // GameScreen::GetType (virtual, vtable slot 10 - a `mov eax,imm ret` stub, so calling. + // The MiscSettingsScreen ctor's "opened from" argument is MainMenuScreen or PauseScreen. static constexpr std::size_t game_screen_get_type_vtable_slot = 10; static constexpr int screen_type_pause = 0x10'00'03; // sgg::ScreenType::Pause @@ -2450,8 +2278,7 @@ namespace big::mod_settings return get_type(opened_from) == screen_type_pause; } - // The context in which a setting may actually be changed. Authors declare it, but the master "enabled" toggle and - // any restart_required setting are. Forced to main_menu because neither can take effect on the live save. + // Authors declare editability, but the master "enabled" toggle and restart_required settings are forced to main_menu. static editable_context effective_editable_context(const std::optional& meta, bool is_enabled_toggle) { if (is_enabled_toggle) @@ -2478,8 +2305,7 @@ namespace big::mod_settings } } - // The note shown in the description box for a row that is read-only because of its editable context. Empty for - // `any` (never restricted). + // The description-box note for a row blocked by its editable context. static std::string context_note(editable_context ctx) { switch (ctx) @@ -2491,9 +2317,7 @@ namespace big::mod_settings } } - // Description-box text for a context-restricted (editableContext-blocked) row: the scenario note on the first line(s), - // then the row's normal description below it, so the box explains BOTH why the row is read-only here and what it does. - // The break is a '\n', handled like the restart dialog's build_list_message. + // Description-box text for a context-restricted row: scenario note first, then the row's normal description. static std::string note_then_description(const std::string& note, const std::string& description) { if (note.empty()) @@ -2507,9 +2331,7 @@ namespace big::mod_settings return note + "\n" + description; } - // True if the mod's config file `cfg` has a direct config entry at (section, key). Used so a group defers a - // group-consumed desc field (displayName/description/order/hidden) to a real config child of the same name: - // a group's desc table doubles as its children's descriptions, so such a field belongs to the child, not the group. + // True if `cfg` has a direct config entry at (section, key), so group desc fields can defer to real children. static bool config_child_exists(toml_v2::config_file* cfg, const std::string& section, const std::string& key) { if (!cfg) @@ -2524,9 +2346,7 @@ namespace big::mod_settings // warned about (keyed "\0"), so the per-frame rebuild logs each bad target only once. static std::set g_warned_group_overrides; - // The menu path an entry appears at: its `group` override (resolved to a root_section-rooted dotted path) if it has - // one, else the entry's own config section. Author-group segments and config-section names share this path space, - // so navigation, bucketing and RowIdentity all keep using dotted-string paths. + // The menu path an entry appears at: its `group` override if present, otherwise its own config section. static std::string menu_path_of(const std::string& config_section, const std::vector& group) { if (group.empty()) @@ -2542,8 +2362,7 @@ namespace big::mod_settings return p; } - // Finds an author-declared menu group (configDesc `groups`) by its full menu path (walking the tree by the segments - // after root_section), or nullptr if the path names no author group (e.g. it is a config section instead). + // Finds an author-declared menu group by its full menu path. static const menu_group* find_author_group(const std::vector& tree, const std::string& menu_path) { const std::string prefix = std::string(root_section) + "."; @@ -2621,10 +2440,7 @@ namespace big::mod_settings #pragma region Panel builder - // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. Leaf entries render as - // setting rows (bool -> toggle, enum/bounded number -> num box, else a freetext value). - // A menu item is either a leaf setting directly in `section`, or a direct child group (a nested sub-section - // such as "config.biome_pool" while viewing "config"). + // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. struct panel_item { bool is_group = false; @@ -2635,8 +2451,8 @@ namespace big::mod_settings bool is_author_group = false; // group only: declared in configDesc `groups` (not a config section) localized_text author_name; // author-group display name (is_author_group only) localized_text author_description; // author-group description (is_author_group only) - bool has_order = false; - double order = 0.0; + bool has_order = false; + double order = 0.0; std::string sort_name; // resolved display name, the alphabetical fallback sort key bool is_enabled = false; // the mod's master "enabled" toggle (root section only) bool is_action = false; // a config.lua action button (runs a Lua callback, no config value) @@ -2645,8 +2461,7 @@ namespace big::mod_settings bool virtual_interactive = false; // the virtual row has a `set` (an editable get/set widget) }; - // One page of the Mods tab before any native widget exists: the rows in display order, plus what the row builder - // needs to know about the mod as a whole. + // One page of the Mods tab before any native widget exists. struct panel_contents { std::vector items; @@ -2693,9 +2508,7 @@ namespace big::mod_settings return 0; }; - // Creates the child group row at `child_path` (no-op if it already exists). A group declared in configDesc - // `groups` takes its name/order/description from there; otherwise it is config-derived and resolved in the - // render. Its sort name mirrors the label the render picks, so the alphabetical fallback matches what is shown. + // Creates the child group row at `child_path`, using author group metadata when available. auto ensure_group = [&](const std::string& child_path) { if (groups.contains(child_path)) @@ -2722,8 +2535,7 @@ namespace big::mod_settings { g.sort_name = resolve_localized(meta->name); - // Config-derived group: its metadata is configDesc.
., resolved here for order and again - // in the render for name/description. Defers to a real config child named "order". + // Config-derived group metadata comes from configDesc.
.. if (meta->has_order && !config_child_exists(view_cfg, child_path, "order")) { g.has_order = true; @@ -2751,7 +2563,6 @@ namespace big::mod_settings continue; } - // Tracked whatever section is shown, so nested rows are greyed when the mod is disabled. if (!out.enabled_entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) { out.enabled_entry = entry.get(); @@ -2766,8 +2577,7 @@ namespace big::mod_settings continue; } - // `group` is a static field, so the cheap (no-Lua) stored metadata is enough to place the entry. - // Everything else still uses its real config section. + // `group` is static, so the cheap stored metadata is enough to place the entry. const auto static_meta = get_setting_metadata(stem, key.m_section, key.m_key); const std::vector grp = static_meta ? static_meta->group : std::vector{}; std::string child_path; @@ -2828,8 +2638,7 @@ namespace big::mod_settings items.push_back(std::move(it)); } - // Interleaved with the settings by `order`/source rank. A dynamic field on a row that lands on THIS page makes - // an edit re-run this build, for live refresh. + // Interleaved with settings by `order` and source rank. for (const auto& vr : get_virtual_rows(stem, "")) { std::string child_path; @@ -2870,9 +2679,7 @@ namespace big::mod_settings } } - // Row order: the master "enabled" toggle is pinned to the top, then rows carrying an authored `order` (ascending), - // then everything else alphabetically by its displayed name. Sorting on the display name (not the config key) - // keeps the list alphabetical in whatever language is active, matching what the player actually reads. + // Row order: pinned "enabled", authored `order`, then displayed name in the active language. std::stable_sort(items.begin(), items.end(), [](const panel_item& a, const panel_item& b) @@ -2899,7 +2706,6 @@ namespace big::mod_settings return out; } - // Builds the native widget for each collected row, in order, appending to g_rows. static void build_panel_rows(MiscSettingsScreen* screen, const std::string& stem, const std::string& section, const panel_contents& contents) { const bool mod_enabled = contents.mod_enabled; @@ -2910,9 +2716,7 @@ namespace big::mod_settings const bool is_enabled_row = it.is_enabled; const bool disabled = !is_enabled_row && !mod_enabled; - // An action button runs a Lua callback (config.lua `action`). It edits no config value. It is greyed and - // inert when the mod is disabled, when the author marked it `disabled`, or when its editable_context does - // not match the current screen (main-menu vs in-save). + // An action button runs a Lua callback and edits no config value. if (it.is_action) { if (it.action.has_dynamic) @@ -2925,14 +2729,10 @@ namespace big::mod_settings const std::string name = resolve_localized(it.action.name); const std::string label = escape_markup(name.empty() ? key_to_display(it.key) : name); - // A mod-off action is hard-disabled (block_input). An author-disabled or context-blocked action is only - // greyed (block_input=false), so it stays focusable/hoverable to show its note - clicks are still - // blocked by pr.disabled in the OnClicked hook. This mirrors context-restricted settings. + // Author-disabled and context-blocked actions stay hoverable to show their notes. if (auto* row = make_button_row(screen, label.c_str(), act_disabled, /*block_input*/ mod_off)) { - // A mod-off action button is fully inert: clearing mSelectable makes MenuScreen::SetMouseOver skip - // it so it never highlights or takes the selection, and m_can_be_focused = false blocks keyboard - // focus too. A soft-disabled action keeps both so it can be highlighted (mouse) to read its note. + // A mod-off action button is fully inert, while soft-disabled actions remain highlightable for their notes. if (mod_off) { row->m_can_be_focused = false; @@ -2940,9 +2740,7 @@ namespace big::mod_settings } else { - // Soft-disabled (author-disabled or context-blocked) but kept useable so it can be highlighted - // to show its note. Clear the hover + selection overlays so it does not flash a clickable - // glow on mouse-over (the grey label already signals it is disabled, like a text row). + // Clear hover and selection overlays so soft-disabled action buttons do not flash clickable. *reinterpret_cast(reinterpret_cast(row) + sgg::gui_component_button_under_mouse_texture_offset) = 0; if (g_set_selected_texture) { @@ -2953,9 +2751,7 @@ namespace big::mod_settings pr.disabled = act_disabled; pr.target_section = it.action.section; // the section the action's callback lives in. - // A context mismatch shows the scenario note first, then the normal description below it. An - // author-disabled action shows its disabledDescription (falling back to the normal description) so - // the author can explain why it is greyed. + // Context-blocked rows show the scenario note first. Author-disabled rows show disabledDescription. if (ctx_blocked) { pr.description = @@ -2975,8 +2771,7 @@ namespace big::mod_settings continue; } - // A virtual row's value comes from Lua callbacks, not a config entry. There is no `hidden`: with no backing - // state, an author omits one by not declaring it. + // A virtual row's value comes from Lua callbacks, not a config entry. if (it.is_virtual) { // A `group` override can move a virtual row onto a page whose path differs from its config section, so @@ -2987,8 +2782,7 @@ namespace big::mod_settings const std::string vlabel = escape_markup(!vname.empty() ? vname : key_to_display(it.key)); const std::string vdesc = vmeta ? resolve_localized(vmeta->description) : std::string{}; - // A read-only virtual row, or an interactive row with no widget, becomes key + value text. mIsUseable - // stays on so the mouse can still resolve it for the description. + // Read-only virtual rows stay mouse-resolvable for their descriptions. const auto build_readonly = [&](const std::string& value_text) { if (auto* row = make_text_row(screen, vlabel.c_str(), /*disabled*/ false, /*block_input*/ false, /*no_hover_highlight*/ true)) @@ -3051,8 +2845,6 @@ namespace big::mod_settings const double step = (vmeta && vmeta->has_step) ? vmeta->step : 1.0; const bool is_stepper = !is_enum && is_number && vmeta && vmeta->has_min && vmeta->has_max && !vmeta->freetext; - // The current value serialized the same way config values/enum options are, for enum matching and the - // read-only fallback. std::string vv_serialized; switch (vv.type) { @@ -3062,8 +2854,7 @@ namespace big::mod_settings default: break; } - // The whole mod being disabled greys the widget (native look). Author `disabled` or an editableContext - // mismatch render the value read-only with a note, like a config setting. + // Author-disabled or context-blocked interactive rows render read-only with a note. const bool author_disabled = vmeta && vmeta->disabled; const editable_context ctx = vmeta ? vmeta->context : editable_context::any; const bool context_blocked = is_context_restricted(ctx); @@ -3095,8 +2886,6 @@ namespace big::mod_settings } } - // Read-only presentation for a context/author-disabled interactive row (unless the mod is fully off, - // whose native greying covers it below). if (!disabled && (context_blocked || author_disabled)) { std::string vtext; @@ -3160,8 +2949,7 @@ namespace big::mod_settings } else { - // get() returned a string with no `values`, or nil: interactive free-text virtual rows are not - // supported yet, so show the current value read-only rather than an uneditable input. + // Interactive free-text virtual rows are not supported yet, so show the current value read-only. build_readonly(truncate_value(vv_serialized)); continue; } @@ -3201,7 +2989,7 @@ namespace big::mod_settings } // A nested group drills into its child menu path when activated. A config-derived group takes its name and - // description from its configDesc entry; an author group carries its own, captured during collection. + // description from its configDesc entry. Author groups carry their own descriptions, captured during collection. if (it.is_group) { std::string glabel; @@ -3257,7 +3045,6 @@ namespace big::mod_settings const std::string& key = it.key; auto* entry = it.entry; - // Author metadata (if any) can rename the row, hide it, and (later) pick its widget. const auto meta = resolved_metadata(stem, entry->m_definition.m_section, entry->m_definition.m_key); if (meta && meta->hidden) { @@ -3270,15 +3057,13 @@ namespace big::mod_settings const std::string mname = meta ? resolve_localized(meta->name) : std::string{}; const std::string label = escape_markup(!mname.empty() ? mname : key_to_display(key)); - // An enum cycles its label list in a num-box; a bounded number gets a slider unless the author set `freetext` - // (better for a very large range). Everything else is a freetext-editable right-column value. + // Enums use num-boxes, bounded numbers use sliders unless `freetext` is set, and everything else uses freetext. const bool is_number = entry->type() == typeid(double); const bool is_enum = meta && !meta->values.empty(); const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max && !meta->freetext; const double step = (meta && meta->has_step) ? meta->step : 1.0; - // Enum option lists (serialized values + parallel labels), resolved once so the widget and the PanelRow - // share them. The current value maps to its index, defaulting to 0. + // Enum option lists are resolved once so the widget and PanelRow share them. std::vector enum_values; std::vector enum_labels; int enum_index = 0; @@ -3286,8 +3071,7 @@ namespace big::mod_settings { enum_values = meta->values; - // Labels parallel the values when the author supplied a full set (each resolved to the current - // language) otherwise the raw values double as their own labels. + // Labels parallel the values when the author supplied a full set, otherwise values label themselves. if (meta->labels.size() == enum_values.size()) { for (const auto& lbl : meta->labels) @@ -3317,14 +3101,12 @@ namespace big::mod_settings bool built_enum = false; bool built_toggle = false; - // A context-blocked or author-disabled setting still takes focus, so the description box can explain why it - // is unavailable. Edits are blocked by pr.disabled in the row handlers. + // Context-blocked and author-disabled settings still take focus so the description can explain why. const editable_context ctx = effective_editable_context(meta, is_enabled_row); const bool context_blocked = is_context_restricted(ctx); if (!disabled && (context_blocked || author_disabled)) { - // Greyed but still visible: keep the real widget focusable so the description can explain why it is - // unavailable. Edits are blocked by pr.disabled. + // Greyed widgets stay focusable for their descriptions. Edits are blocked by pr.disabled. GUIComponent* ro_row = nullptr; GUIComponent* ro_value = nullptr; bool ro_is_toggle = false; @@ -3353,7 +3135,6 @@ namespace big::mod_settings } if (!ro_row) { - // Plain string (or a widget that could not be built): greyed key + value text row. const std::string vtext = truncate_value(entry->get_serialized_value()); ro_row = make_text_row(screen, label.c_str(), /*disabled*/ true, /*block_input*/ false); if (ro_row) @@ -3392,9 +3173,7 @@ namespace big::mod_settings pr.value_component = ro_value; } - // A context mismatch shows the scenario note first, then the normal description below it. An - // author-disabled row shows its disabledDescription (falling back to the normal description) so the - // author can explain why it is greyed. + // Context-blocked rows show the scenario note first. Author-disabled rows show disabledDescription. if (context_blocked) { pr.description = note_then_description(context_note(ctx), meta ? resolve_localized(meta->description) : std::string{}); @@ -3421,8 +3200,7 @@ namespace big::mod_settings } else if (is_stepper) { - // Bounded number: a slider (drag bar) like the audio-volume rows, snapped to step. Fall back to a - // number-box stepper if the slider cannot be built on this game build. + // Bounded numbers use sliders, falling back to a number-box if the slider cannot be built. row = make_slider_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), meta->show_as_percentage, meta->is_percentage, disabled); if (row) { @@ -3436,7 +3214,6 @@ namespace big::mod_settings } else { - // Left-aligned key + right-aligned value (two components), like a keybind row. row = make_text_row(screen, label.c_str(), disabled); if (row) { @@ -3483,7 +3260,6 @@ namespace big::mod_settings } } - // One page of a mod's settings: collect what belongs on it, then build a native row for each. static void build_mod_settings(MiscSettingsScreen* screen, const std::string& stem, const std::string& section) { build_panel_rows(screen, stem, section, collect_panel_items(stem, section)); @@ -3531,16 +3307,14 @@ namespace big::mod_settings } } - // The component the user is currently on: the mouse-over one (mouse) takes priority, else the selected one - // (keyboard/controller). These are MenuScreen fields (flat struct view). + // The component the user is currently on: mouse-over first, otherwise keyboard/controller selection. static GUIComponent* active_row_component(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); return menu->m_mouse_over_component ? menu->m_mouse_over_component : menu->m_selected_component; } - // Finds the PanelRow whose left-column component is `comp`, or nullptr. Valid until the next panel rebuild - // (deferred to Update), so callers within a single input/update pass may keep it. + // Finds the PanelRow whose left-column component is `comp`. Valid until the next panel rebuild. static PanelRow* find_row(GUIComponent* comp) { if (!comp) @@ -3558,7 +3332,6 @@ namespace big::mod_settings } // Builds a stable identity for a row so it can be re-found after a rebuild recreates the components. - // A row's real config section, used to tell same-named keys apart when a `group` override moves them onto one page. static std::string row_config_section_of(const PanelRow& r) { if (r.entry) @@ -3613,12 +3386,10 @@ namespace big::mod_settings return g_mouse_button_down ? g_mouse_button_down(input) : true; } - // The component whose description was last written to the description box. The box is only updated when the - // highlighted row changes (not every frame). Reset when the panel rebuilds. + // The component whose description was last written to the description box. static GUIComponent* g_last_description_component = nullptr; - // Shows the highlighted row's author description in the screen's native description box - // (MiscSettingsScreen::mDescriptionBox @ 0x460). Otherwise the box is cleared. + // Shows the highlighted row's author description in the native description box. static void sync_description_box(MiscSettingsScreen* screen) { if (!g_show_text || !screen->m_description_box) @@ -3629,7 +3400,6 @@ namespace big::mod_settings GUIComponent* active = active_row_component(screen); - // Resolve the highlighted row's description (cheap linear scan over the few visible rows). const std::string* description = nullptr; if (PanelRow* row = find_row(active)) { @@ -3637,15 +3407,11 @@ namespace big::mod_settings } const bool show = description && !description->empty(); - // Rebuild the text only when the highlighted row changes (ShowText re-lays out the lines). if (active != g_last_description_component) { g_last_description_component = active; - // Escape markup so paths/brackets in the description render verbatim (see escape_markup), then turn any embedded - // newline into the box's hard-break escape. GUIComponentTextBox::Parse strips a raw 0x0A but honors the escape "\n" - // (backslash + n) as a wrap-independent hard break (via ParseEscapeSequence), so a context note stays on its own - // line above the description while each part still word-wraps. + // Escape markup and convert embedded newlines to the text box's hard-break escape. std::string shown; if (show) { @@ -3666,15 +3432,12 @@ namespace big::mod_settings } } - // Re-apply the fade every frame: the native Update runs before this and re-hides. The box on the Mods tab (it - // does not use mDescriptionBox here), so a one-time set would fade back out. + // Re-apply the fade every frame because native Update runs before this and re-hides the box. box->m_fade_opacity = show ? 1.0f : 0.0f; box->m_fade_target = show ? 1.0f : 0.0f; } - // Last label we wrote to each bottom-prompt button, so SetDisplayName is only called when the label actually - // changes (avoids re-laying out the text every frame). Cleared when we leave the Mods tab so the native labels take - // back over and re-entering re-applies ours. + // Last label we wrote to each bottom-prompt button, so SetDisplayName only runs when the label changes. static std::string g_prompt_confirm_label; static std::string g_prompt_cancel_label; @@ -3704,13 +3467,10 @@ namespace big::mod_settings auto* menu = reinterpret_cast(screen); - // The native prompt strings embed a glyph token that the text box expands to the device- appropriate key icon:. - // Labels are upper-case to match the game Cancel (Esc): "CANCEL" while editing a field "BACK" inside a mod's settings - // (Esc returns to the mod list, see the ExitScreen hook) "EXIT" at the mod list (closes the options screen). + // Labels are upper-case to match the game: "CANCEL" while editing, "BACK" in settings, "EXIT" at the mod list. const char* cancel = g_editing ? "{CN} CANCEL" : (g_view == View::mod_settings ? "{CN} BACK" : "{CN} EXIT"); set_prompt_label(menu->m_cancel_button, g_prompt_cancel_label, cancel); - // Confirm (Enter): "SUBMIT" while editing otherwise a verb matching the highlighted row. std::string confirm; if (g_editing) { @@ -3720,8 +3480,6 @@ namespace big::mod_settings { if (row->disabled) { - // A greyed, non-interactable row (e.g. an opted-out mod) has no confirm action, so show no confirm - // prompt for it. confirm.clear(); } else @@ -3757,10 +3515,7 @@ namespace big::mod_settings } } - // Drive the Confirm prompt's visibility ourselves: native only fades it in (OnOptionMouseOver) for its OWN option - // rows, which never fires for our custom rows Show it with its glyph whenever we have a hint, hide it when we don't - // mFadeOpacity is the field the draw gate reads native Update rewrites mHidden each frame, so both are set here - // (after the original Update). + // Drive Confirm prompt visibility ourselves because native OnOptionMouseOver never fires for our custom rows. if (menu->m_confirm_button) { if (confirm.empty()) @@ -3875,9 +3630,7 @@ namespace big::mod_settings screen->m_category_focused = false; } - // The row a pending back-navigation should re-focus: the mod_entry row of the mod that was open (focus_stem set), - // or the group row that drills into the section that was open (focus_section set). Exactly one of the two fields is - // set per restore. Returns nullptr if that row is not in the freshly built view (e.g. it was removed since). + // The row a pending back-navigation should re-focus. static GUIComponent* restore_target_row(const NavRestore& r) { for (const auto& row : g_rows) @@ -3927,9 +3680,7 @@ namespace big::mod_settings return (g_input_get_state(input, control) & 0x4u) != 0; } - // Holds the clicked row as moused-over and selected, and re-applies our prompt and description, for a few frames - // after a click-triggered rebuild. The native hover pass runs later in HandleInput and over freshly laid-out rows can - // transiently resolve the stationary cursor to a neighbour, which would blink the highlight and prompt. + // Holds the clicked row as moused-over and selected for a few frames after a click-triggered rebuild. static void reassert_keep_active_row(MiscSettingsScreen* screen) { if (!(g_use_mouse && *g_use_mouse)) @@ -3945,8 +3696,7 @@ namespace big::mod_settings menu->m_mouse_over_component = keep; menu->m_selected_component = keep; - // Clear the prompt caches and the last-description marker so this frame's sync re-applies our label and text, - // overriding a native clear on the rebuild frame. + // Clear caches so this frame's sync overrides a native clear on the rebuild frame. g_prompt_confirm_label.clear(); g_prompt_cancel_label.clear(); g_last_description_component = nullptr; @@ -3961,10 +3711,7 @@ namespace big::mod_settings reinterpret_cast(fn)(comp); } - // Moves a row (and its owned child components) to an absolute location via the engine's own SetLocation (GUIComponent - // vtable slot +0x180) - the same call UpdateScrollState uses to lay rows on the grid. Going through SetLocation - // (rather than writing m_location_y directly) keeps a row's children - a slider's bar/label, a button's label - in - // step, avoiding the per-frame drift a raw location write causes. + // Moves a row through the engine's own SetLocation so child components stay in step. static void set_component_location(GUIComponent* comp, float x, float y) { char* vtable = *reinterpret_cast(comp); @@ -3993,9 +3740,7 @@ namespace big::mod_settings if (row.is_slider) { - // Moused-over look lives on the left label textbox. Revert it unless this row is the live mouse-over - - // but a disabled (greyed, still-selectable) row is reverted even while hovered, so its bar/label never - // light up: it must read as greyed no matter the cursor. + // Disabled still-selectable sliders are reverted even while hovered so they stay greyed. if (row.disabled || row.component != menu->m_mouse_over_component) { if (auto* label = *reinterpret_cast(s + slider_label_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) @@ -4004,8 +3749,6 @@ namespace big::mod_settings } } - // Focused look lives on mFocused/the value textbox. Revert it unless this row is the focused option (a - // disabled row is never focused, so it is always reverted here). if ((row.disabled || row.component != screen->m_component_focused) && *reinterpret_cast(s + slider_focused_offset)) { call_component_vfn(row.component, vtable_on_focus_off_offset); @@ -4013,9 +3756,7 @@ namespace big::mod_settings } else if ((row.is_enum || row.is_stepper) && (mouse_mode || row.disabled)) { - // Num-box selected look (black box + green label) set by OnSelected, on the label textbox mUseSelected - // flag. Revert via OnUnselected (not OnMouseOff, a no-op here) unless it is the live mouse-over - a - // disabled (greyed, still-selectable) row is reverted even while hovered so it stays greyed. + // Num-box selected look is reverted through OnUnselected because OnMouseOff is a no-op here. if (row.disabled || row.component != menu->m_mouse_over_component) { if (auto* label = *reinterpret_cast(s + numbox_label_text_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) @@ -4027,10 +3768,7 @@ namespace big::mod_settings } } - // Keeps a greyed-but-still-selectable widget row's label (and value) text greyed. We re-apply the grey through the - // text box's own SetTextColor each frame (the same call the engine's UpdateButtonStates uses to grey a still-hoverable - // option), which writes that cached mTextColor directly. The selected-colour def is greyed too (grey_text_box) so a - // hover that briefly sets the selected flag stays grey. + // Keeps a greyed-but-still-selectable widget row's label and value text greyed. static void keep_disabled_labels_grey() { const auto grey_label = [](char* base, std::size_t tb_offset) @@ -4093,8 +3831,7 @@ namespace big::mod_settings static void build_panel(MiscSettingsScreen* screen, bool instant = false) { - // A rebuild frees and recreates the row components, so the cached highlighted-row pointer is stale force the - // description box to refresh next frame. + // A rebuild frees the highlighted-row pointer, so refresh the description next frame. g_last_description_component = nullptr; // Preserve the current scroll offset across an in-place refresh (same view/mod, e.g. after committing a setting @@ -4120,7 +3857,6 @@ namespace big::mod_settings } } - // Remove any rows from a previous view/visit before building the new set. destroy_rows(screen); // Resolve the blank graphic lazily: the string-intern table is not ready at hook registration time, so "Blank" @@ -4136,7 +3872,6 @@ namespace big::mod_settings // toggle should trigger an in-place rebuild to re-evaluate it live. g_view_has_dynamic = false; - // This rebuild supersedes any pending numeric-change refresh, so cancel its debounce. g_dynamic_refresh_settle = 0.0f; if (g_view == View::mod_settings && !g_view_stem.empty()) @@ -4183,13 +3918,9 @@ namespace big::mod_settings } } - // A view change leaves the freshly built rows at mFadeOpacity 0 (finalize_row). The native ease - // (GUIComponent::Update) then fades the on-page rows in toward mFadeTarget == 1, matching the game's own - // category-switch transition. + // A view change leaves fresh rows transparent so native GUIComponent::Update fades them in. sync_value_columns(); - // Take the disabled/greyed rows out of the keyboard/controller nav so the cursor only lands on interactable - // ones (mouse hover is unaffected). apply_row_freeform_selectability(); // On a real view change (tab entry, drilling in, going back), drop the cursor on the first row so it highlights @@ -4209,8 +3940,7 @@ namespace big::mod_settings } else if (had_cursor) { - // Put the keyboard/controller cursor back on the equivalent new row so an instant rebuild (a toggle, an - // action, or the deferred numeric refresh) does not drop it. No-op for mouse. + // Put the keyboard/controller cursor back on the equivalent new row after an instant rebuild. for (const auto& row : g_rows) { if (row.component && row.kind == cursor_kind && row.setting_key == cursor_key && !row.disabled @@ -4222,8 +3952,7 @@ namespace big::mod_settings } } - // Arm a short re-assert window after a click-triggered instant rebuild (a toggle or an action). The Update hook - // re-asserts the clicked row over these frames (see reassert_keep_active_row). + // Arm a short re-assert window after a click-triggered instant rebuild. if (instant && g_keep_active_row.valid && g_use_mouse && *g_use_mouse) { g_keep_active_frames = keep_active_frame_count; @@ -4244,11 +3973,10 @@ namespace big::mod_settings // same view/mod (e.g. after toggling "enabled") is applied instantly to avoid a fade flash. static void apply_nav(MiscSettingsScreen* screen) { - // A rebuild that stays on the same view/mod/section (a setting edit, an "enabled" toggle, or a Reset) is - // applied instantly, which preserves the current scroll page instead of snapping back to the top. + // Same-view rebuilds preserve the current scroll page instead of snapping back to the top. const bool instant = (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); - // Maintain the restore stack. A same-view rebuild (instant:. + // Maintain the restore stack. const bool drilling_in = (g_view == View::mod_list && g_pending_view == View::mod_settings) || (g_view == View::mod_settings && g_pending_view == View::mod_settings && g_pending_section.rfind(g_view_section + ".", 0) == 0); @@ -4287,9 +4015,7 @@ namespace big::mod_settings #pragma region Reset to defaults - // The serialized default of a config entry, read from the entry itself via the public write_description (whose last - // output line is "#. The serialized form uses the same converter as get_serialized_value, so it round-trips through - // set_serialized_value. + // The serialized default of a config entry, read via write_description and round-tripped through set_serialized_value. static std::optional entry_default_serialized(toml_v2::config_file::config_entry_base* entry) { if (!entry) @@ -4335,17 +4061,14 @@ namespace big::mod_settings } auto* e = entry.get(); - // Reset only the settings the menu actually shows: described keys (from our loader or a Chalk - // plain-string description) plus the always-shown master "enabled" toggle. Hidden (undescribed) keys - // are the mod's internal state, so a menu Reset leaves them untouched. + // Reset only shown settings plus the master "enabled" toggle, leaving hidden internal state untouched. const bool is_enabled_toggle = def.m_section == root_section && e->type() == typeid(bool) && is_enabled_key(def.m_key); if (!is_enabled_toggle && !setting_is_described(guid, def.m_section, def.m_key) && !entry_has_description(e)) { continue; } - // Skip entries outside the current menu group. The `group` override is a static field, so the cheap - // stored metadata gives the placement. + // Skip entries outside the current menu group. const auto static_meta = get_setting_metadata(guid, def.m_section, def.m_key); const std::vector grp = static_meta ? static_meta->group : std::vector{}; const std::string mpath = resolve_entry_menu_path(guid, author_groups, cfg, def.m_section, grp); @@ -4357,8 +4080,7 @@ namespace big::mod_settings auto def_val = get_setting_default(guid, def.m_section, def.m_key); if (!def_val) { - // Not bound via rom.mod_settings.load (e.g. a Chalk mod): recover the default from the config entry - // itself. + // Chalk mods recover the default from the config entry itself. def_val = entry_default_serialized(e); } if (!def_val || e->get_serialized_value() == *def_val) @@ -4469,9 +4191,7 @@ namespace big::mod_settings return build_list_message("A restart is required because you changed these settings:", lines, "The game will now close. Please restart it to apply the changes."); } - // Builds an empty EASTL SSO string (24-byte layout) in `buf` (>=24 bytes). Passed to the dialog ctor (message) and - // AddScreen (name). The real message is applied afterwards via ShowText. Layout: bytes[0..]=chars, - // byte[23]=remaining-capacity marker (23 - length). + // Builds an empty EASTL SSO string in `buf`. The real message is applied afterwards via ShowText. static void make_eastl_sso(char* buf, const char* text) { std::size_t n = std::strlen(text); @@ -4498,7 +4218,7 @@ namespace big::mod_settings // Shows the native single-button message box, modal over the options screen. When confirm_closes_game is set the // confirm button is captured so the OnClicked hook closes the game on press (a forced restart, which must not be - // cancellable); otherwise the button keeps its native dismiss behaviour. + // cancellable). Otherwise the button keeps its native dismiss behaviour. static bool show_message_dialog(void* screen_manager, const char* title, const std::string& message, bool confirm_closes_game) { if (screen_manager && g_message_dialog_ctor && g_add_screen) @@ -4510,21 +4230,17 @@ namespace big::mod_settings { std::memset(dialog, 0, message_dialog_size); - // The ctor builds every component (single button + text) and loads GUI/MessageDialog.sjson. Pass an - // empty message. The real (multi-line) text is applied below via ShowText so it need not be an eastl - // heap string. + // Pass an empty message so the real multi-line text can be applied below via ShowText. char empty_message[24]; make_eastl_sso(empty_message, ""); g_message_dialog_ctor(dialog, screen_manager, empty_message); auto* bytes = reinterpret_cast(dialog); - // Ensure the dialog is visible and modal over the options screen. bytes[screen_removed_offset] = 0; bytes[screen_visible_offset] = 1; bytes[screen_block_input_offset] = 1; - // Set the title + body (raw text. The body carries the list of settings/mods). if (g_show_text) { if (auto* title_box = *reinterpret_cast(bytes + dialog_title_offset)) @@ -4533,15 +4249,12 @@ namespace big::mod_settings } if (auto* message_box = *reinterpret_cast(bytes + dialog_message_offset)) { - // Shrink the body font: the sjson template renders at size 26 scale the live font handle's size - // ratios down before ShowText lays out the lines (the def's mFontSize is ignored once the - // template is loaded). + // Shrink the loaded font handle before ShowText lays out the lines. char* handle = reinterpret_cast(message_box) + textbox_font_handle_offset; *reinterpret_cast(handle + font_handle_size_ratio_offset) *= restart_message_font_scale; *reinterpret_cast(handle + font_handle_eng_size_ratio_offset) *= restart_message_font_scale; - // Escape markup so a path value (e.g. hadesGameFolder) with '\' or brackets in the listed lines - // renders verbatim (see escape_markup). + // Escape markup so path values render verbatim. const std::string shown = escape_markup(message); g_show_text(message_box, shown.c_str()); } @@ -4556,7 +4269,7 @@ namespace big::mod_settings g_restart_dialog = dialog; } - // Add at the END of the screen list so it draws on top of the options menu. + // Add at the end of the screen list so it draws on top. char empty_name[24]; make_eastl_sso(empty_name, ""); g_add_screen(screen_manager, dialog, true, empty_name); @@ -4567,15 +4280,13 @@ namespace big::mod_settings return false; } - // The "restart required" prompt: its only button closes the game (a restart-required change must not be - // cancellable, since cancelling would have to undo the change). + // The restart-required prompt. Its only button closes the game. static bool show_restart_dialog(void* screen_manager, const std::string& message) { return show_message_dialog(screen_manager, "Restart Required", message, /*confirm_closes_game*/ true); } - // The "can't disable this mod" prompt: purely informational, so its button just dismisses the dialog and returns - // the player to the options screen with the mod left enabled. + // The dependency-block prompt is informational, so its button just dismisses the dialog. static bool show_dependency_dialog(void* screen_manager, const std::string& message) { return show_message_dialog(screen_manager, "Cannot Disable Mod", message, /*confirm_closes_game*/ false); @@ -4603,9 +4314,7 @@ namespace big::mod_settings return true; } - // Display names of the currently-enabled loaded mods that declare `stem` as a dependency (via their Thunderstore - // manifest, which lists dependency guids in dependencies_no_version_number). A dependent that is itself disabled is - // skipped -. + // Display names of enabled loaded mods that declare `stem` as a Thunderstore dependency. static std::vector active_dependents_of(const std::string& stem) { std::vector result; @@ -4678,7 +4387,6 @@ namespace big::mod_settings g_in_hub = game_is_in_hub(); g_options_screen_open = true; - // The engine constructor returns `this` forward it unchanged auto* screen = static_cast(big::g_hooking->get_original()(self, screen_manager, opened_from, profile_name)); if (!mods_category_button(screen)) @@ -4727,9 +4435,7 @@ namespace big::mod_settings return result; } - // Value-change hook for our native number-box rows. GUIComponentNumBox::SetNumberValue is called (with notify=true) on - // every user step - left/right, arrow click, keyboard or controller. This fires for native settings num-boxes too, - // hence the `find_row` filter. + // Value-change hook for our native number-box rows, filtered because it also fires for native settings num-boxes. static void hook_GUIComponentNumBox_SetNumberValue(void* self, float value, bool notify) { big::g_hooking->get_original()(self, value, notify); @@ -4745,8 +4451,7 @@ namespace big::mod_settings return; } - // Enum cycler: The box tracks the option index persist the matching serialized value and replace the raw index - // the original just painted with the option's label. + // Enum cyclers persist the matching serialized value and repaint the option label. if (row->is_enum) { int idx = static_cast(*reinterpret_cast(reinterpret_cast(self) + numbox_value_offset)); @@ -4792,7 +4497,6 @@ namespace big::mod_settings const double step_v = row->stepper_step; const double range = max_v - min_v; - // Continuous post-clamp fraction the original just wrote, mapped to the value and snapped to step. const float f = *reinterpret_cast(reinterpret_cast(self) + slider_fraction_offset); double v = min_v + static_cast(f) * range; if (step_v > 0.0 && range > 0.0) @@ -4810,8 +4514,6 @@ namespace big::mod_settings commit_row_number(row, v); - // Restore the real value in place of the percentage the original wrote (applying the setting's own - // percentage-display options). set_slider_value_text(reinterpret_cast(self), format_setting_display(v, row->show_as_percentage, row->is_percentage, step_v).c_str()); } @@ -5033,10 +4735,7 @@ namespace big::mod_settings return result; } - // Restores vertical breathing room around action-button rows, whose taller Button_Secondary box would otherwise - // crowd neighbouring setting rows on the uniform grid. Runs after the native UpdateScrollState has laid out the - // page. The shift goes through the engine's own SetLocation so each row's child components follow - a raw - // m_location_y write leaves them behind, which is what caused the earlier slider-bar drift. + // Restores vertical breathing room around action-button rows after native UpdateScrollState lays out the page. static void apply_button_spacing(MiscSettingsScreen* screen) { const std::size_t first = screen->m_page_start_index; @@ -5096,7 +4795,6 @@ namespace big::mod_settings *reinterpret_cast(bytes + component_auto_activate_offset) = true; }; - // Down arrow aims one row below the last visible row. Up arrow one row above the first visible row. aim(screen->m_down_arrow, g_rows[last].component, row_pitch); aim(screen->m_up_arrow, g_rows[first].component, -row_pitch); } @@ -5125,8 +4823,7 @@ namespace big::mod_settings auto* screen = static_cast(self); const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); - // Freetext editing: refresh the edited row's live label Confirm/cancel is handled in the HandleInput hook so - // the submitting key/click is swallowed on the same frame. + // Freetext editing refreshes here. Confirm/cancel is handled in the HandleInput hook. if (g_editing) { if (on_mods_tab) @@ -5167,8 +4864,7 @@ namespace big::mod_settings g_pending_section = g_view_section; g_nav_pending = true; - // Pin the edited row so the rebuild keeps focus on it. Keyboard/controller focus is restored by - // build_panel's cursor tracking. + // Pin the edited row so the rebuild keeps focus on it. if (GUIComponent* active = active_row_component(screen)) { if (const PanelRow* fr = find_row(active)) @@ -5183,7 +4879,6 @@ namespace big::mod_settings if (g_nav_pending) { - // Only act while this screen is actually showing the Mods tab. if (on_mods_tab) { // Stepping from a mod's settings back to the mod overview is the "done configuring this mod" point: if a diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 7b0d9ef..b137e3c 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -10,17 +10,13 @@ namespace big::mod_settings void register_hooks(); void bind_config_api(sol::state_view& state, sol::table& lua_ext); - // A user-facing string an author may write in config.lua either plainly ("Enable feature") or as a localization - // table keyed by the game's language folder codes ({ en = "...", de = "...", ["zh-TW"] = "..." }). Stored as - // language-code -> text, with a plain string kept under the empty key. The settings menu resolves it to the current - // game language at render time (see resolve_localized), falling back to English then any entry. + // A user-facing string, plain or localized: language-code -> text, with a plain string under the empty key. + // Resolved to the current game language at render, falling back to English then any entry. using localized_text = std::map; - // When a setting may be changed, relative to whether a save is loaded. The Lua state is recreated when a save is - // loaded, so init-time changes (GameData edits, function patches) only take effect if made before that point, while - // some settings only apply to a live run. A row whose context does not match is greyed with a note. - // any: anywhere. main_menu: forced for the master "enabled" toggle and any restartRequired setting. - // in_save: hub and mid-run. in_hub: the Crossroads only, for settings unsafe to change during a run. + // Where a setting may be edited: anywhere, only from the main menu, only while a save is loaded, or only in the hub + // (the Crossroads). Off-context rows are greyed with a note. main_menu is forced for the master "enabled" toggle and + // any restartRequired setting. enum class editable_context { any, @@ -29,10 +25,8 @@ namespace big::mod_settings in_hub, }; - // An author-forced widget kind for a virtual row (config.lua `type`). Virtual rows normally infer their widget - // from get()'s value type, but get() may return nil at build time (the mod's state is not ready yet), which would - // fall back to a read-only row. Declaring `type` forces the widget regardless. Ignored for config-backed settings - // (their value always exists). `enumeration` is only needed when there is no `values` list to imply it. + // Forces a virtual row's widget kind (config.lua `type`) when it cannot be inferred from get(). Ignored for + // config-backed settings. `enumeration` is only needed without a `values` list. enum class widget_type { inferred, @@ -42,9 +36,8 @@ namespace big::mod_settings enumeration, }; - // An author-declared menu category (configDesc `groups`) that does NOT correspond to a config section, letting a mod - // present a flat or differently-nested config under an arbitrary menu tree via a per-entry `group`. `id` is the - // identity a `group` path references (the table key in configDesc.groups). + // An author-declared menu category (configDesc `groups`) with no matching config section. `id` is what a per-entry + // `group` path references. struct menu_group { std::string id; @@ -55,23 +48,18 @@ namespace big::mod_settings std::vector children; }; - // The author-declared menu group tree (configDesc `groups`) for mod `guid`, empty when none was declared. The - // settings menu uses it for the display name/order/description of groups a per-entry `group` references but that - // do not exist as config sections. Populated fresh each Lua-state init by rom.mod_settings.load. + // The author-declared menu group tree for mod `guid`, empty when none was declared. std::vector mod_menu_groups(const std::string& guid); - // Author-declared metadata for one setting, extracted from its config.lua description table. Only settings whose - // description is a rich table have an entry; the rest fall back to type-based rendering. Every field is an - // author-only input that cannot be inferred from the config value, and all are optional (see the has_* flags). + // Author-declared metadata for one setting, from its config.lua description table. Only settings described with a + // rich table get an entry - the rest fall back to type-based rendering. All fields are optional (see the has_*). struct setting_metadata { localized_text name; // display-name override (empty -> prettified key) localized_text description; // same text written to the .cfg comment - // Shown in the description box in place of `description` while the row is greyed by its own `disabled` field, - // so the author can explain why it is unavailable. Empty -> fall back to `description`. Not applied to a - // context-restricted row (which shows its own where-to-change note) or a mod-disabled row (the off mod toggle - // already explains that). May be a plain string, a localization table, or a dynamic (function) field. + // Shown instead of `description` while the row is greyed by its own `disabled` (empty -> use `description`). + // Not applied to a context-restricted or mod-disabled row, which show their own note. localized_text disabled_description; bool has_min = false; @@ -81,78 +69,59 @@ namespace big::mod_settings bool has_step = false; double step = 0.0; - // Enum options: serialized option values and parallel display labels (labels default to the values when - // omitted). Serialized form matches the config entry's serialization. Each label may be localized. + // Enum options and their parallel display labels (labels default to the values). Serialized like the config + // entry's value. std::vector values; std::vector labels; bool has_order = false; - double order = 0.0; // author-declared sort key (lower first), unset -> map order + double order = 0.0; // author-declared sort key (lower first), unset -> alphabetical by display name bool hidden = false; // author asked to omit this row entirely bool disabled = false; // render greyed and non-interactive but still visible (may be dynamic) bool restart_required = false; // change only takes effect after a game restart bool freetext = false; // force a bounded number to freetext entry (not the stepper) - // When this setting may be changed relative to a loaded save (see editable_context). Default `any`. Forced to - // `main_menu` for the master "enabled" toggle and for restart_required settings. editable_context context = editable_context::any; - - // True if any field in this setting's config.lua description is a Lua function (a dynamic field, e.g. `max = - // function() ... end`). Such fields are skipped at load and re-evaluated at render via - // resolve_setting_metadata, so the menu reflects the current game state. + // A field written as a Lua function, skipped at load and re-evaluated at render via resolve_setting_metadata. bool has_dynamic = false; - // Number-display options (mainly for the slider) is_percentage shows a 0..1 value as 0..100 and appends "%" - // show_as_percentage only appends "%" without scaling. Setting show_as_percentage in addition to is_percentage - // is a no-op. The stored config value is never modified by either. + // is_percentage shows a 0..1 value as 0..100 and appends "%", show_as_percentage only appends it. Neither + // changes the stored value. bool show_as_percentage = false; bool is_percentage = false; - // Virtual-row only (config.lua `type`/`default`). `type` forces the widget kind when get() cannot be relied - // on to infer it (see widget_type). `default` is the value a menu Reset restores the row to, via its set() - // callback (config settings recover their own default from the .cfg / config.lua instead), stored serialized - // like an enum option value. Both are ignored for config-backed settings. + // Virtual-row only. `default` is the value a menu Reset restores through set(), serialized like an enum option. widget_type type = widget_type::inferred; bool has_default = false; std::string default_value; - // Menu placement override (configDesc `group`): the author-declared menu path this entry appears under instead - // of its config-section default. Empty -> placed by its config section. Each segment is a config child section - // or an author group declared in configDesc `groups` (see menu_group). Applies to settings, actions and - // virtual rows alike. + // Menu path this entry appears under instead of its config section (configDesc `group`), empty -> its config + // section. Each segment is a config child section or a declared author group. std::vector group; }; - // True if a mod author declared this setting as requiring a game restart to take effect (via `restart_required = - // true` in the setting's config.lua description). Populated by rom.mod_settings.load. Consulted by the settings - // menu when a value changes. + // True if the author declared this setting as requiring a game restart to take effect. bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key); - // Returns the author-declared metadata for a setting, or std::nullopt when the setting has no rich metadata table - // (in which case the menu renders it with type-based defaults). + // The author-declared metadata for a setting, or nullopt when it has no rich metadata table (the menu then renders + // it with type-based defaults). std::optional get_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); - // True if (section, key) carries a configDesc entry (a description string or a table). The menu shows only - // described keys. An undescribed config key is hidden, so a mod's internal/bookkeeping config values do not clutter - // the settings page. The mod's master "enabled" toggle is always shown regardless (handled in build_mod_settings). + // True if (section, key) carries a configDesc entry. The menu shows only described keys, plus the master "enabled" + // toggle regardless. bool setting_is_described(const std::string& guid, const std::string& section, const std::string& key); - // True when the game is currently in the hub (the Crossroads): the game Lua global `CurrentHubRoom` is non-nil. - // Reads the game's Lua state (shared with mods), so it must be called on the game thread while the state is alive. - // The settings menu uses it to gate `editableContext = "inHub"` rows (editable only in the hub, not mid-run). - // Returns false when the Lua state is unavailable. + // True when the game is in the hub (the Crossroads), i.e. the game Lua global `CurrentHubRoom` is non-nil. Gates + // `editableContext = "inHub"` rows. bool game_is_in_hub(); - // Like get_setting_metadata, but re-evaluates the setting's dynamic (Lua-function) fields against the current game - // state. Call on the game thread, while the Lua state is alive, when get_setting_metadata reports has_dynamic. The - // returned metadata never has has_dynamic set. nullopt for settings with no stored description (e.g. Chalk-bound). + // Like get_setting_metadata, but re-evaluates the setting's dynamic fields against the current game state. Call + // when get_setting_metadata reports has_dynamic. The result never has has_dynamic set. std::optional resolve_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); - // A menu button declared in config.lua that runs a Lua callback instead of editing a config value: an `action = - // function() ... end` entry in the configDesc, which has no config counterpart. Placed among the setting rows by - // `order`. + // A configDesc entry with an `action` function and no config value: a button that runs a Lua callback. struct action_info { std::string section; // config section the action lives in (drilldown level) @@ -168,17 +137,15 @@ namespace big::mod_settings std::vector group; // menu placement override (configDesc `group`), empty -> config section }; - // The action buttons declared directly in config `section` of mod `guid` (not recursing into child sections). - // Dynamic fields are resolved against the current game state (call on the game thread). + // The action buttons declared directly in config `section` of mod `guid` (not recursing), with their dynamic fields + // resolved against the current game state. std::vector get_actions(const std::string& guid, const std::string& section); - // Runs a config.lua action button's Lua callback protected, with errors logged. No-op if the guid/section/key - // does not resolve to an action. Call on the game thread while the Lua state is alive. + // Runs an action button's Lua callback protected, logging errors. No-op if (guid, section, key) is not an action. void invoke_action(const std::string& guid, const std::string& section, const std::string& key); // A configDesc entry with NO backing config value, marked `virtual = true`. Its value comes from Lua callbacks: a - // read-only row uses `text`, an interactive row `get`/`set`. The callables stay in the Lua descs registry and are - // resolved at render; the rest of its metadata is read like a config setting's, via resolve_setting_metadata. + // read-only row uses `text`, an interactive row `get`/`set`. The rest of its metadata is read like a setting's. struct virtual_row_info { std::string section; @@ -190,16 +157,14 @@ namespace big::mod_settings std::vector group; // menu placement override (configDesc `group`), empty -> config section }; - // The virtual (non-config) rows declared directly in config `section` of mod `guid` (not recursing into child - // sections). The order is unspecified (the menu sorts rows itself, by `order` then display name). + // The virtual rows declared directly in config `section` of mod `guid` (not recursing). Order is unspecified - the + // menu sorts rows itself. std::vector get_virtual_rows(const std::string& guid, const std::string& section); - // The display string for a READ-ONLY virtual row, from its `text` (a string or a function returning one) callback. - // Call on the game thread while the Lua state is alive. Empty when the row is unavailable or has no `text`. + // The display string for a READ-ONLY virtual row, from its `text` callback. Empty if it has none. std::string get_virtual_display(const std::string& guid, const std::string& section, const std::string& key); - // A virtual row's current typed value, read from its Lua `get()` callback. The kind determines which widget an - // interactive virtual row builds (like a config value's type does for a config row). + // A virtual row's current typed value. The kind decides which widget an interactive virtual row builds. struct virtual_value { enum class kind @@ -215,39 +180,27 @@ namespace big::mod_settings std::string as_string; }; - // Reads an interactive virtual row's current value by calling its `get()` callback (protected). Returns kind::none - // when the row has no `get`, is unavailable, or the call fails. Call on the game thread while the Lua state is - // alive. + // Reads an interactive virtual row's value through its `get()` callback. kind::none if it has no `get` or the call + // fails. virtual_value get_virtual_value(const std::string& guid, const std::string& section, const std::string& key); - // Writes a new value to an interactive virtual row by calling its `set(value)` callback (protected). No-op when the - // row has no `set`. Call on the game thread while the Lua state is alive. + // Writes a virtual row's value through its `set(value)` callback. No-op when it has no `set`. void set_virtual_value(const std::string& guid, const std::string& section, const std::string& key, const virtual_value& value); - // Restores one interactive virtual row of mod `guid` (identified by its config `section` and `key`) to its declared - // `default`, via its set() callback. No-op returning false if the row is not interactive, declares no `default`, or - // already holds it. The menu Reset scopes which rows to restore by their menu path and calls this per row (config- - // backed settings recover their own defaults separately). Call on the game thread while the Lua state is alive. + // Restores one interactive virtual row to its declared `default` through set(), returning whether it changed. The + // menu Reset scopes which rows to restore by menu path and calls this per row. bool reset_virtual_row_to_default(const std::string& guid, const std::string& section, const std::string& key); - // Returns the config.lua default (serialized like the config entry's value) for a setting bound via - // rom.mod_settings.load, or std::nullopt for keys with no captured default. Used by the settings menu's. Reset - // action to restore a setting to what config.lua declared. + // The config.lua default for a setting bound via rom.mod_settings.load, serialized like the config entry's value. std::optional get_setting_default(const std::string& guid, const std::string& section, const std::string& key); - // True if a mod called rom.mod_settings.opt_out() from its Lua (keyed by the calling mod's guid, which matches a - // mod config's file stem). The settings menu still lists such a mod, but greys its row, blocks drilling into it, - // and shows an opt-out note in place of its description. Populated fresh each Lua-state init (opt_out re-runs with - // the mod's main.lua). + // True if a mod called rom.mod_settings.opt_out(). The menu still lists it, but greys its row and blocks drilling in. bool mod_opted_out(const std::string& guid); - // The optional custom description a mod passed to rom.mod_settings.opt_out(description) (empty when none was - // given). The settings menu shows it (resolved to the current language) in place of the generic opt-out note when - // the mod's greyed row is highlighted. + // The custom description passed to opt_out(), shown in place of the generic note. Empty when none was given. localized_text mod_opt_out_description(const std::string& guid); - // True while a setting change should notify its mod through an on_change callback: a native options screen is - // currently open. Consulted by the config API so an on_change fires for any edit made through the options menu (main - // menu or in a save), but not from a mod's own config write outside the menu. + // True while a setting change should fire its mod's onChanged callback: a native options screen is open, so menu + // edits notify the mod but its own config writes do not. bool on_change_callbacks_enabled(); } // namespace big::mod_settings From 56bf0718d617d689ba34db838692f56db81cf1f1 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:05:15 +0100 Subject: [PATCH 079/100] Updated Readme --- .../tables/definitions/rom.mod_settings.lua | 17 +- docs/lua/tables/rom.mod_settings.md | 18 +- docs/mod_settings/README.md | 155 ++++++++-------- docs/mod_settings/config_schema.lua | 172 +++++++++--------- src/hades2/mod_settings/config_api.cpp | 26 +-- src/hades2/mod_settings/mod_settings.cpp | 10 +- src/hades2/mod_settings/mod_settings.hpp | 3 +- 7 files changed, 190 insertions(+), 211 deletions(-) diff --git a/docs/lua/tables/definitions/rom.mod_settings.lua b/docs/lua/tables/definitions/rom.mod_settings.lua index 92f5725..5e192e7 100644 --- a/docs/lua/tables/definitions/rom.mod_settings.lua +++ b/docs/lua/tables/definitions/rom.mod_settings.lua @@ -2,14 +2,15 @@ ---@class (exact) rom.mod_settings --- Loads a mod's config.lua and registers its settings under the Mods tab of the in-game Options --- menu, returning a live read/write proxy over the config. When using this, you do not need to depend on `Chalk`. ----@param config_lua string Path, relative to the mod's folder, of the config.lua that returns `config, configDesc`. ----@return table # A live read/write proxy over the mod's config; index it to read a setting and assign to write one. -function mod_settings.load(config_lua) end +-- Loads a mod's `config.lua` and registers its settings under the Mods tab of the in-game Options +-- menu, returning a live read/write proxy over the config. Also manages the mod's `.cfg` file, +-- setting default values for new options and loading values saved to it by users. When using this, +-- your mod does not need to depend on or use `Chalk`. +---@param configFilePath string Path, relative to the mod's folder, of the `config.lua` that returns `config` and `configDesc`. +---@return table # A live read/write proxy over the mod's config. Index it to read a setting and assign to write one. +function mod_settings.load(configFilePath) end -- Excludes the calling mod from the in-game mod settings menu: it stays listed but will be greyed out and --- cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod --- should not be edited in-game. Works with Chalk or rom.mod_settings.load. ----@param description? string A plain string or a localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note when the mod's greyed row is highlighted. +-- cannot be opened. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. +---@param description? string A plain string or a localization table `{ en = "...", de = "..." }` shown in place of the generic note when the mod's disabled row is hovered. function mod_settings.opt_out(description) end diff --git a/docs/lua/tables/rom.mod_settings.md b/docs/lua/tables/rom.mod_settings.md index e53d977..615c4f1 100644 --- a/docs/lua/tables/rom.mod_settings.md +++ b/docs/lua/tables/rom.mod_settings.md @@ -2,30 +2,30 @@ ## Functions (2) -### `load(config_lua)` +### `load(configFilePath)` -Loads a mod's config.lua and registers its settings under the Mods tab of the in-game Options menu, returning a -live read/write proxy over the config. When using this, you do not need to depend on `Chalk`. +Loads a mod's `config.lua` and registers its settings under the Mods tab of the in-game Options menu, returning a +live read/write proxy over the config. Also manages the mod's `.cfg` file, setting default values for new options +and loading values saved to it by users. When using this, your mod does not need to depend on or use `Chalk`. - **Parameters:** - - `config_lua` (string): Path, relative to the mod's folder, of the config.lua that returns `config, configDesc`. + - `configFilePath` (string): Path, relative to the mod's folder, of the `config.lua` that returns `config` and `configDesc`. - **Returns:** - - `table`: A live read/write proxy over the mod's config; index it to read a setting and assign to write one. + - `table`: A live read/write proxy over the mod's config. Index it to read a setting and assign to write one. **Example Usage:** ```lua -table = rom.mod_settings.load(config_lua) +config = rom.mod_settings.load("config.lua") ``` ### `opt_out(description)` Excludes the calling mod from the in-game mod settings menu: it stays listed but will be greyed out and -cannot be opened, with a note pointing the player to the mod's own description. Use it when the mod -should not be edited in-game. Works with Chalk or rom.mod_settings.load. +cannot be opened. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. - **Parameters:** - - `description` (string): Optional. A plain string or a localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note when the mod's greyed row is highlighted. + - `description` (string): Optional. A plain string or a localization table `{ en = "...", de = "..." }` shown in place of the generic note when the mod's disabled row is hovered. **Example Usage:** ```lua diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index 9021856..d8a257e 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -3,25 +3,21 @@ Hell2Modding renders each mod's config file as a tab in the game's Options screen. Mods declare how their settings look and read/write their values through a `config.lua` that returns two tables: -- `config` - the default values (and the live values once loaded). +- `config` - the config keys and their default values. - `configDesc` - the description/metadata for each setting (labels, help text, ranges, enums, ...). +Loading the `config.lua` also writes the mod's `.cfg`: it is created from the declared defaults on first run, and +rewritten on later runs to pick up newly added keys and descriptions, keeping any values already saved there. + > **Only keys with a `configDesc` entry are shown.** A key present in `config` but absent from `configDesc` -> is treated as internal state and is not displayed in the menu (a group whose keys are all undescribed -> produces no row at all). A `configDesc` entry can be either a metadata table or a plain description string - -> either counts as "described". The one exception is the mod's master `enabled` toggle, which is always shown -> so the mod stays toggleable even when it is not described. Reset to defaults likewise only affects the -> keys the menu shows. +> is treated as internal state and is not displayed in the menu. -This folder ships [LuaCATS](https://luals.github.io/wiki/annotations/) definitions -([`config_schema.lua`](./config_schema.lua)) so that VS Code gives you **autocomplete** and **hover -documentation** while you write `configDesc`, plus **field type checking** on settings you annotate -directly (see below). +## VS Code type hints -## Enabling it in VS Code +To get schema validation and type hints to show in VS Code when you edit your `config.lua`, follow these steps: 1. Install the [Lua extension](https://marketplace.visualstudio.com/items?itemName=sumneko.lua) for VS Code. -2. In the extension's settings, add the folder containing the `config_schema.lua` to the `workspace.library` array. +2. In the extension's settings, add the folder containing the `config_schema.lua` from this repository to the `workspace.library` array. 3. Annotate the `configDesc` table with `---@type mod_settings.config_desc`. ## Field reference @@ -33,41 +29,36 @@ below. Two other kinds of `configDesc` entry have their own fields and sections: | Field | Type | Purpose | | --- | --- | --- | -| `displayName` | string \| localization table \| callback | Row label (defaults to a prettified key). | -| `description` | string \| localization table \| callback | Help text in the description box. Keep each line ~35 chars to leave space for free-text input strings. | -| `min`/`max` | number \| callback | Numeric bounds. If both are present the input will turn into a slider (such as for volume control). | +| `displayName` | string \| localization table \| callback | Row label (defaults to a prettified key). Keep it to ~35 characters so it leaves room for the value shown to its right. | +| `description` | string \| localization table \| callback | Help text shown in the description box at the bottom of the screen while the row is highlighted. Keep it to ~450 characters. | +| `min`/`max` | number \| callback | Numeric bounds. If both are present the input will turn into a slider. | | `step` | number \| callback | Slider/number step size (default 1). Will clamp user input automatically. | -| `values` | array \| callback | Enum: the values stored in the `.cfg` file. If present, the input will turn into a cycler (such as for the selected display). | -| `labels` | array of (string \| localization table) \| callback | Display labels parallel to `values`, only used in the in-game mod menu. | -| `order` | number \| callback | Sort key for custom ordering config entries in the menu, lower first. Rows carrying an `order` are listed above those without one. When omitted, rows are sorted alphabetically by their `displayName`. | -| `hidden` | boolean | Hide the setting from the menu entirely. Static only - use `disabled` for a condition that changes while the menu is open. | -| `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. See below. | -| `disabledDescription` | string \| localization table \| callback | Description shown in place of `description` while the setting is greyed by its own `disabled` field, to explain why. Falls back to `description` when omitted. Not used for context-restricted or mod-disabled rows. | -| `freetext` | boolean | Force a bounded number to be a free-text entry instead of a slider. | -| `restartRequired` | boolean | Force the user to restart the game when this setting is changed. | -| `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict where the row can be edited: `"any"` (default), `"mainMenu"` ( only from the main menu), `"inSave"` (only while a save is loaded), or `"inHub"` (only in the Crossroads). Outside of the allowed context the row shows as disabled. In most cases, `any` will work, only restrict when actively changing a live value during gameplay, or save-specific data. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | -| `showAsPercentage` | boolean | Append "%" to the value. | -| `isPercentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". | -| `onChanged` | `fun(key, new_value)` | Called after the setting is changed through the menu, in any context. See below. | - -## Config keys named like reserved fields - -If you happen to name a config key after one of the reserved fields above, the menu will still render them correctly, -but it is highly recommended to **not** use reserved field names as config keys to prevent confusion and potential edge -case breakage. +| `values` | array \| callback | Enum: the values stored in the `.cfg` file. If present, the input will turn into a cycler. | +| `labels` | array of (string \| localization table) \| callback | Display labels to show instead of the underlying `values` in the mod menu. Keep each to ~20 characters. | +| `order` | number \| callback | Sort key for custom ordering config entries in the menu, lowest first. Rows carrying an `order` are listed above those without one. When omitted, rows are sorted alphabetically by their `displayName`. | +| `hidden` | boolean | Hide the setting from the menu entirely. Static only - use `disabled` for rows that change state while the menu is open. | +| `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. | +| `disabledDescription` | string \| localization table \| callback | Description shown in place of `description` while the setting is greyed by its own `disabled` field, to explain why. Falls back to `description` when omitted. | +| `restartRequired` | boolean | Force the user to restart the game after existing the mod menu if this setting was changed. | +| `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict where the row can be edited: `"any"` (default), `"mainMenu"` (only from the main menu), `"inSave"` (only while a save is loaded), or `"inHub"` (only in the Crossroads). Outside of the allowed context the row shows as disabled. Restrict this if the mod or game would break if the setting is edited in the wrong context. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | +| `showAsPercentage` | boolean | Append "%" to the value. Usually used for min/max restricted number fields. | +| `isPercentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". You don't need `showAsPercentage` when using this. | +| `onChanged` | `fun(key, new_value)` | Called after the setting is changed through the menu. | + +Try to avoid naming your config keys after any of the reserved fields above. ## Menu grouping (`group` and `groups`) The in-game menu layout can be **decoupled** from your config file structure. `configDesc` must still mirror the config (`config.debugging.logLevel` is described at `configDesc.debugging.logLevel`), but where each row *appears* in the menu -is independent: +can be independent: - By default a row appears under its **config section** - so a nested config nests in the menu automatically. - Add a **`group`** property to any entry (setting, action, or virtual row) to move it into a different menu category. It is a string for a single level, or an array for a nested path. This works for flat *and* nested config keys, and doesn't change where the value is stored in the .cfg file. - Declare menu categories that do **not** exist as config sections in a top-level **`groups`** table (keyed by the id - used in a `group`), each with an optional `displayName`, `description`, `order`, and nested `groups`. + used in a `group`), each with an optional `displayName`, `description`, `order`, and further nested `groups`. This lets you keep a flat config but present any grouping you like, or re-nest an already-nested config another way. @@ -75,50 +66,45 @@ This lets you keep a flat config but present any grouping you like, or re-nest a Most fields can also be dynamically resolved through a function call, which is evaluated when the menu is opened and refreshed (after any other setting is changed). This lets a setting react to the live game -state or to other settings. The following may be a **function** returning the value instead of a -literal: `displayName`, `description`, `disabledDescription`, `min`, `max`, `step`, `values`, `labels`, `order`, and -`disabled`. The function runs in your mod's environment, so it can read your `config`, and call functions -in your `mod` or the `game` namespace. +state or to other settings. The function runs in your mod's environment, so it can read your `config`, +and call functions in your `mod` or the `game` namespace. Examples: ```lua -biome_count = { - displayName = "Number of Regions", +revive_count = { + displayName = "Allowed Revives", min = 2, - max = function() return mod.MaxAllowedBiomeCount end, -- 8 or 12, resolved live + -- Max could be dependent on internal mod state + max = function() return mod.CalcNumAllowedRevives() end, + -- Perhaps mod.CalcNumAllowedRevives() accesses the game's GameState, in which case it would error when called in the Main Menu + editableContext = "inSave", }, -meta_reward_fix_chance_cap = { - displayName = "Meta Reward Chance Cap", - min = 30, max = 90, - disabled = function() return not mod.config.meta_reward_fix end, -- greyed unless the fix toggle is on - disabledDescription = "Enable \"Fix Meta Reward Count\" above to change this.", -- shown while greyed +revive_chance = { + displayName = "Chance to automatically revive", + description = "After dying without any Death Defiance left, you have a chance to automatically respawn at the start of the encounter." + min = 0, + max = 100, + -- Row is greyed/disabled unless another config value is toggled on + disabled = function() return not mod.config.easy_mode end, + disabledDescription = "Enable \"Easy Mode\" above to change this.", }, ``` -Use `disabled` (greys the row in place) for a condition that changes while the menu is open. `hidden` is -static only - it is evaluated only when the menu builds, and cannot be changed dynamically. Pair `disabled` -with `disabledDescription` to explain why the row is greyed: while the row is disabled by its own `disabled` -field, the description box shows `disabledDescription` instead of the normal `description` (falling back to -`description` if you omit it). A greyed row still highlights on mouse hover so the note is readable. This does -not apply to context-restricted rows (which show their own "change it in X" note) or while the whole mod is -disabled. - ## Action buttons A `configDesc` entry with an `action` function (and a key that has NO config value) renders as a button that runs the callback when pressed, instead of editing a setting. It supports `displayName`, `description`, -`disabledDescription`, `order`, `editableContext`, and `disabled` (grey the button live, e.g. until a value -has changed). +`disabledDescription`, `order`, `editableContext`, and `disabled`. ```lua apply_scaling = { - action = function() mod.ApplyLateBiomeScaling() end, - displayName = "Apply Late Biome Scaling", - description = "Apply the scaling values above to the current run.", - editableContext = "inSave", -- greyed unless a save is loaded - disabled = function() return not mod.HasUnappliedScaling() end, -- greyed until a value changes - disabledDescription = "Change a scaling value above to enable this.", -- shown while greyed + action = function() mod.ApplyEasyModeScaling() end, + displayName = "Apply Easy Mode Scaling", + description = "Apply the scaling values above to the current save file.", + editableContext = "inSave", + disabled = function() return not mod.HasUnappliedEasyModeScaling() end, + disabledDescription = "Change a scaling value above to enable this.", }, ``` @@ -133,8 +119,8 @@ A virtual row is either **read-only** or **interactive**: the value to show. - **Interactive:** give it `get` (reads the current value) and `set` (writes the edited value). The widget is inferred from `get()`'s value and the metadata, exactly like a config setting is inferred from its config - value: a **boolean** is a toggle, a **number** with `min`+`max` is a slider (otherwise a number box), and any - type with a `values` list is an **enum picker**. + value: a **boolean** is a toggle, a **number** with `min`+`max` is a slider (otherwise a freetext field), + and any type with a `values` list is an **enum picker**. Interactive rows also support `disabled`, `disabledDescription`, `editableContext`, `showAsPercentage`/ `isPercentage`, and (for enums) `labels` - the same as config settings. `get`/`set`/`text` and the metadata @@ -143,19 +129,22 @@ fields may be functions, re-evaluated live. Two extra fields help interactive rows that have no `.cfg` backing: - **`type`** - force the widget kind (`"boolean"`, `"number"`, `"string"`, or `"enum"`) when `get()` can - return `nil` at build time and so cannot be inferred. Only needed then; `"enum"` still requires `values`. -- **`default`** - the value the menu **Reset** restores the row to, applied through its `set()` callback. - Config-backed settings recover their own default automatically; a virtual row without a `default` is left - untouched by Reset. + return `nil` at build time and so cannot be inferred. +- **`default`** - the value the menu's **Reset** button restores the row to, applied through its `set()` callback. + A virtual row without a `default` is left untouched by Reset. ```lua -local preset = nil -- not chosen yet, so get() returns nil until the player picks one +-- Not chosen yet, so get() returns nil until the player picks one +mod.EasyModePreset = nil local configDesc = { preset = { - virtual = true, displayName = "Preset", - type = "enum", values = { "off", "balanced", "max" }, default = "balanced", - get = function() return preset end, - set = function(v) preset = v end, + virtual = true, + displayName = "Difficulty Preset", + type = "enum", + values = { "off", "balanced", "max" }, + default = "balanced", + get = function() return mod.EasyModePreset end, + set = function(v) mod.EasyModePreset = v end, }, } ``` @@ -163,16 +152,18 @@ local configDesc = { ## Reacting to changes (`onChanged`) Give a setting an `onChanged` function to react when the player changes it through the options menu. Use it to -apply the new value to the live game, and/or to update **other rows'** dynamic `min`/`max`/`values`/`disabled`. +apply the new value to the live game, and/or to update **other rows'** dynamic fields. It receives the setting's key and the new value: ```lua local configDesc = { - hermes_shrine_chance = { - displayName = "Hermes Shrine Chance", + run_difficulty = { + displayName = "Run difficulty", min = 0, max = 100, - onChanged = function(key, new_value) - if game.CurrentRun then mod.ApplyHermesShrineChance(new_value) end + onChanged = function(key, newValue) + if game.CurrentRun then + mod.ApplyNewRunDifficulty(newValue) + end end, }, } @@ -181,8 +172,6 @@ local configDesc = { The callback fires AFTER the new value is stored and the `.cfg` is saved, so reading the setting back (directly or via your `config` proxy) returns the new value. Note: -- It **fires in any context** (main menu or in a save), so guard anything that needs a live run - `CurrentRun` - and `GameState` are absent in the main menu. - It is **not called for other config writes** (e.g. from imgui or the config file) - only for edits made through this menu. - Re-writing the same value is a no-op and does not fire, so an `onChanged` that writes another setting @@ -191,6 +180,6 @@ The callback fires AFTER the new value is stored and the `.cfg` is saved, so rea ## Localization tables -Any `displayName`, `description`, or `labels` entry may be a table keyed by the game's language folder +Any `displayName`, `description`, or `labels` entry may be a table keyed by the game's language codes (`en`, `de`, `el`, `es`, `fr`, `it`, `ja`, `ko`, `pl`, `pt-BR`, `ru`, `tr`, `uk`, `zh-CN`, -`zh-TW`). The menu resolves it to the current game language, falling back to English. +`zh-TW`). The menu resolves it to the currently set language, falling back to English. diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index abe80b8..e72c3a8 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -1,8 +1,8 @@ ---@meta --- A user-facing string. Either a plain string, or a localization table keyed by the game's language ---- folder codes (en, de, el, es, fr, it, ja, ko, pl, pt-BR, ru, tr, uk, zh-CN, zh-TW). It is resolved ---- to the current game language when the menu is shown, falling back to English. +--- codes (en, de, el, es, fr, it, ja, ko, pl, pt-BR, ru, tr, uk, zh-CN, zh-TW). It is resolved to the +--- currently set language when the menu is shown, falling back to English. --- Example: `{ en = "Difficulty", de = "Schwierigkeit" }` ---@alias mod_settings.localized_string string | table @@ -12,11 +12,11 @@ ---@alias mod_settings.dynamic_boolean boolean | fun(): boolean ---@alias mod_settings.dynamic_string mod_settings.localized_string | fun(): mod_settings.localized_string ---- A menu placement path. The in-game menu layout is decoupled from the config file structure: by default a +--- A menu placement path. The in-game menu layout can be decoupled from the config file structure: by default a --- setting appears under its config section (so a nested config nests in the menu), but a `group` moves it into --- a different or brand-new menu category instead. A single string is a one-level group; an array is a nested --- path (e.g. { "Debugging", "Logging" }). configDesc must still mirror the config structure (debugging.logLevel ---- in config is debugging.logLevel in configDesc). Supplying `group` only changes where a row is shown, not wherer +--- in config is debugging.logLevel in configDesc). Supplying `group` only changes where a row is shown, not where --- its value lives in the .cfg file. ---@alias mod_settings.group string | string[] @@ -27,10 +27,10 @@ ---@field displayName? mod_settings.localized_string --- Help text shown while the category's row is highlighted. ---@field description? mod_settings.localized_string ---- Sort key among sibling categories/rows, lower first. Entries with an `order` are listed above those without one, ---- which are sorted alphabetically by their displayName. +--- Sort key among sibling categories/rows, lowest first. Entries with an `order` are listed above those without +--- one, which are sorted alphabetically by their displayName. ---@field order? number ---- Nested sub-categories, keyed by their id (referenced as later path segments in a `group`). +--- Further nested sub-categories, keyed by their id (referenced as later path segments in a `group`). ---@field groups? table @@ -38,56 +38,54 @@ --- widget type is inferred from the setting's config value (a boolean becomes a toggle; a number with `min` --- and `max` becomes a slider; a value with `values` becomes a cycler; anything else is a free-text field). ---@class (exact) mod_settings.setting_description ---- Help text shown at the bottom of the options menu while the config rows is highlighted. Recommended to keep ---- to about 35 characters so it leaves enough space for free-text input strings. ----@field description? mod_settings.dynamic_string --- Row label. Defaults to a prettified version of the config key (e.g. `myCool_Setting` -> "My Cool Setting"). +--- Recommended to keep to about 35 characters so it leaves enough space for the value shown to its right. ---@field displayName? mod_settings.dynamic_string ---- Lower bound for a numeric setting. Combined with `max`, the setting renders as a slider. ----@field min? mod_settings.dynamic_number ---- Upper bound for a numeric setting. Combined with `min`, the setting renders as a slider. ----@field max? mod_settings.dynamic_number ---- Step between values for a slider and free-text number inputs. Defaults to 1. ----@field step? mod_settings.dynamic_number ---- Enum options: the values actually stored in the .cfg file. ---- Providing this makes the setting a cycler over these options. ----@field values? (string | number | boolean)[] | fun(): (string | number | boolean)[] ---- Display labels shown for each entry of `values` (same order, same number of entries). Each label may be a ---- localization table. When omitted, the raw values are shown in the cycler. ----@field labels? mod_settings.localized_string[] | fun(): mod_settings.localized_string[] ---- Sort key among sibling categories/rows, lower first. Entries with an `order` are listed above those without one, ---- which are sorted alphabetically by their displayName. +--- Help text shown in the description box at the bottom of the screen while the row is highlighted. Recommended +--- to keep to about 450 characters. +---@field description? mod_settings.dynamic_string +--- Sort key among sibling categories/rows, lowest first. Entries with an `order` are listed above those without +--- one, which are sorted alphabetically by their displayName. ---@field order? mod_settings.dynamic_number ---- Hide this setting from the menu entirely. Static only (evaluated when the menu builds) - for a ---- condition that changes while the menu is open, use `disabled`, which greys the setting out. +--- Move this row to a different or new menu category, overriding its config-section placement (see mod_settings.group). +---@field group? mod_settings.group +--- Hide this setting from the menu entirely. Static only (evaluated when the menu builds) - use `disabled` for +--- rows that change state while the menu is open. ---@field hidden? boolean --- Grey the setting out (shown read-only, cannot be changed) while this is true. Unlike `hidden`, a `disabled` --- change updates live while the menu is open (e.g. grey a slider unless its parent toggle is enabled). ---@field disabled? mod_settings.dynamic_boolean --- Description shown in place of `description` while the setting is greyed by its own `disabled` field, to ---- explain why it is unavailable. Ignored for a context-restricted row (only editable in main menu etc.) or ---- while the whole mod is disabled. Defaults to the normal `description` when omitted. +--- explain why it is unavailable. Defaults to the normal `description` when omitted. ---@field disabledDescription? mod_settings.dynamic_string ---- Force a bounded number (one with `min` and `max`) to a free-text text field instead of a slider. ----@field freetext? boolean ---- Mark that changing this setting requires a game restart. The menu forces the player ---- to restart when they leave the mod menu after changing it. ----@field restartRequired? boolean --- Restrict where this row can be edited: only the main menu, only in a save, only in the Crossroads ---- or anywhere (default). Outside of the allowed context the row shows as disabled. ---- In most cases, "any" will work, only restrict when actively changing a live value during gameplay, or save-specific data. +--- or anywhere (default). Outside of the allowed context the row shows as disabled. Restrict this if the mod or +--- game would break if the setting is edited in the wrong context. --- The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. ---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" ---- Append "%" to the displayed value. ----@field showAsPercentage? boolean ---- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). ----@field isPercentage? boolean +--- Force the user to restart the game after exiting the mod menu if this setting was changed. +---@field restartRequired? boolean --- Called after this setting's value is changed through the options menu, with the setting's key and the new value. ---- Fires in any context - guard live-run access (game.CurrentRun and GameState are absent in the main menu). ---- Re-writing the same value is a no-op and does not fire. Errors are logged, not propagated. +--- Not called for config writes made outside the menu. Re-writing the same value is a no-op and does not fire. +--- Errors are logged, not propagated. ---@field onChanged? fun(key: string, new_value: boolean|number|string) ---- Move this row to a different or new menu category, overriding its config-section placement (see mod_settings.group). ----@field group? mod_settings.group +--- Lower bound for a numeric setting. Combined with `max`, the setting renders as a slider. +---@field min? mod_settings.dynamic_number +--- Upper bound for a numeric setting. Combined with `min`, the setting renders as a slider. +---@field max? mod_settings.dynamic_number +--- Step between values for a slider and free-text number inputs. Defaults to 1. +---@field step? mod_settings.dynamic_number +--- Append "%" to the displayed value. Usually used for min/max restricted number fields. +---@field showAsPercentage? boolean +--- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). You don't need +--- `showAsPercentage` when using this. +---@field isPercentage? boolean +--- Enum options: the values actually stored in the .cfg file. +--- Providing this makes the setting a cycler over these options. +---@field values? (string | number | boolean)[] | fun(): (string | number | boolean)[] +--- Display labels to show instead of the underlying `values` in the mod menu (same order, same number of +--- entries). Each label may be a localization table. Recommended to keep each to about 20 characters. +---@field labels? mod_settings.localized_string[] | fun(): mod_settings.localized_string[] --- An action button in the menu that runs a callback instead of editing a config value. Declare it as a --- `configDesc` entry (with a matching key that has NO config value) carrying an `action` function. @@ -98,20 +96,19 @@ ---@field displayName? mod_settings.dynamic_string --- Help text shown while the button is highlighted. ---@field description? mod_settings.dynamic_string ---- Sort key among the section's rows, lower first. +--- Sort key among the section's rows, lowest first. ---@field order? mod_settings.dynamic_number ---- Restrict where the button is enabled: main menu, in a save, in the Crossroads, or anywhere (default "any"). ---- In most cases, "any" will work, only restrict when actively changing a live value during gameplay, or save-specific data. ----@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" +--- Move this button to a different or new menu category, overriding its config-section placement (see mod_settings.group). +---@field group? mod_settings.group --- Grey the button out (non-interactive) while this is true. Updates live while the menu is open (e.g. --- grey an "Apply" button until a value has actually changed). ---@field disabled? mod_settings.dynamic_boolean ---- Description shown in place of `description` while the setting is greyed by its own `disabled` field, to ---- explain why it is unavailable. Ignored for a context-restricted row (only editable in main menu etc.) or ---- while the whole mod is disabled. Defaults to the normal `description` when omitted. +--- Description shown in place of `description` while the button is greyed by its own `disabled` field, to +--- explain why it is unavailable. Defaults to the normal `description` when omitted. ---@field disabledDescription? mod_settings.dynamic_string ---- Move this button to a different or new menu category, overriding its config-section placement (see mod_settings.group). ----@field group? mod_settings.group +--- Restrict where the button is enabled: main menu, in a save, in the Crossroads, or anywhere (default "any"). +--- Restrict this if the mod or game would break if the button is pressed in the wrong context. +---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" --- A virtual row: a menu row that is NOT backed by a `config` value, whose value comes from Lua callbacks. --- Declare it as a `configDesc` entry whose key has NO matching `config` value, with `virtual = true` (required, @@ -119,8 +116,8 @@ --- - READ-ONLY: give it `text` (a string, or a function returning one). --- - INTERACTIVE: give it `get` (read) and `set` (write). The widget is inferred from get()'s value and the --- metadata, exactly like a config setting is inferred from its config value: a boolean is a toggle, a number ---- with `min`+`max` is a slider (else a number box), and any type with `values` is an enum picker. If get() ---- can return nil at build time, force the widget with `type`. Give it a `default` to have the menu Reset +--- with `min`+`max` is a slider (otherwise a freetext field), and any type with `values` is an enum picker. If +--- get() can return nil at build time, force the widget with `type`. Give it a `default` to have the menu Reset --- restore it. --- `get`/`set`/`text` and the metadata fields (displayName/description/values/min/max/step/labels) may all be --- functions, re-evaluated live. @@ -136,48 +133,47 @@ --- INTERACTIVE: writes the edited value back. Required for an interactive row (its presence makes the row --- interactive). For an enum row, receives the selected option as a STRING (the serialized form). ---@field set? fun(value: boolean | number | string) ---- Force the widget kind when get() may return nil at build time (so it cannot be inferred). Only needed then; ---- normally the widget is inferred from get()'s value. `"enum"` still needs `values`. +--- Row label. Defaults to a prettified version of the config key (e.g. `myCool_Setting` -> "My Cool Setting"). +--- Recommended to keep to about 35 characters so it leaves enough space for the value shown to its right. +---@field displayName? mod_settings.dynamic_string +--- Help text shown in the description box at the bottom of the screen while the row is highlighted. Recommended +--- to keep to about 450 characters. +---@field description? mod_settings.dynamic_string +--- Sort key among sibling categories/rows, lowest first. Entries with an `order` are listed above those without +--- one, which are sorted alphabetically by their displayName. +---@field order? number +--- Move this row to a different or new menu category, overriding its config-section placement (see mod_settings.group). +---@field group? mod_settings.group +--- Grey the row out (non-interactive) while this is true. Updates live while the menu is open. +---@field disabled? mod_settings.dynamic_boolean +--- Description shown in place of `description` while the row is greyed by its own `disabled` field, to explain +--- why it is unavailable. Defaults to the normal `description` when omitted. +---@field disabledDescription? mod_settings.dynamic_string +--- Restrict where this row can be edited: main menu, in a save, in the Crossroads, or anywhere (default "any"). +--- Restrict this if the mod or game would break if the row is edited in the wrong context. +---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" +--- Force the widget kind when get() may return nil at build time (so it cannot be inferred). ---@field type? "boolean" | "number" | "string" | "enum" ---- Value the menu Reset restores this row to, via its `set()` callback (config-backed settings recover their ---- own default instead). Rows without a `default` are left untouched by Reset. +--- Value the menu's Reset button restores this row to, via its `set()` callback. Rows without a `default` are +--- left untouched by Reset. ---@field default? boolean | number | string ---- Enum options: the values actually stored in the .cfg file. ---- Providing this makes the setting a cycler over these options. ----@field values? (string | number | boolean)[] | fun(): (string | number | boolean)[] ---- Display labels shown for each entry of `values` (same order, same number of entries). Each label may be a ---- localization table. When omitted, the raw values are shown in the cycler. ----@field labels? mod_settings.localized_string[] | fun(): mod_settings.localized_string[] --- Lower bound for a numeric setting. Combined with `max`, the setting renders as a slider. ---@field min? mod_settings.dynamic_number --- Upper bound for a numeric setting. Combined with `min`, the setting renders as a slider. ---@field max? mod_settings.dynamic_number --- Step between values for a slider and free-text number inputs. Defaults to 1. ---@field step? mod_settings.dynamic_number ---- Append "%" to the displayed value. +--- Append "%" to the displayed value. Usually used for min/max restricted number fields. ---@field showAsPercentage? boolean ---- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). +--- Display a 0..x value as 0..x00 *and* append "%" (the stored value stays 0..x). You don't need +--- `showAsPercentage` when using this. ---@field isPercentage? boolean ---- Grey the button out (non-interactive) while this is true. Updates live while the menu is open (e.g. ---- grey an "Apply" button until a value has actually changed). ----@field disabled? mod_settings.dynamic_boolean ---- Description shown in place of `description` while the setting is greyed by its own `disabled` field, to ---- explain why it is unavailable. Ignored for a context-restricted row (only editable in main menu etc.) or ---- while the whole mod is disabled. Defaults to the normal `description` when omitted. ----@field disabledDescription? mod_settings.dynamic_string ---- Restrict where this row can be edited: main menu, in a save, in the Crossroads, or anywhere (default "any"). ---- In most cases, "any" will work, only restrict when actively changing a live value during gameplay, or save-specific data. ----@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" ---- Row label. Defaults to a prettified version of the config key (e.g. `myCool_Setting` -> "My Cool Setting"). ----@field displayName? mod_settings.dynamic_string ---- Help text shown at the bottom of the options menu while the config rows is highlighted. Recommended to keep ---- to about 35 characters so it leaves enough space for free-text input strings. ----@field description? mod_settings.dynamic_string ---- Sort key among sibling categories/rows, lower first. Entries with an `order` are listed above those without one, ---- which are sorted alphabetically by their displayName. ----@field order? number ---- Move this row to a different or new menu category, overriding its config-section placement (see mod_settings.group). ----@field group? mod_settings.group +--- Enum options: the values actually stored in the .cfg file. +--- Providing this makes the setting a cycler over these options. +---@field values? (string | number | boolean)[] | fun(): (string | number | boolean)[] +--- Display labels to show instead of the underlying `values` in the mod menu (same order, same number of +--- entries). Each label may be a localization table. Recommended to keep each to about 20 characters. +---@field labels? mod_settings.localized_string[] | fun(): mod_settings.localized_string[] --- Each entry in `configDesc` can be a simple key:description string, a setting description table, an action --- button, or a nested table of descriptions mirroring a config group. The underlying .cfg file contents are @@ -185,6 +181,6 @@ --- categories that do not exist as config sections, which entries move into via their `group`. --- --- Only keys with a `configDesc` entry are shown in the menu: a `config` key with no entry here is treated as ---- internal state and hidden (a group whose keys are all undescribed produces no row). The mod's master ---- `enabled` toggle is always shown regardless, so the mod stays toggleable. +--- internal state and hidden. The mod's master `enabled` toggle is always shown regardless, so the mod stays +--- toggleable. ---@alias mod_settings.config_desc table diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 3fc81c9..e7c2b49 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -427,12 +427,6 @@ namespace big::mod_settings m.disabled = disabled_field.as(); } - sol::object freetext_field = desc["freetext"]; - if (freetext_field.is()) - { - m.freetext = freetext_field.as(); - } - sol::object show_pct_field = desc["showAsPercentage"]; if (show_pct_field.is()) { @@ -708,7 +702,6 @@ namespace big::mod_settings "order", "hidden", "disabled", - "freetext", "restartRequired", "editableContext", "showAsPercentage", @@ -1297,11 +1290,12 @@ namespace big::mod_settings } } - // Lua API: Function. Table: mod_settings. Name: load. Param: config_lua: string: Path, relative to the mod's - // folder, of the config.lua that returns `config, configDesc`. Returns: table: A live read/write proxy over the - // mod's config - index it to read a setting, assign to write one. Registers the mod's settings under the Mods tab - // of the Options menu. Replaces depending on `Chalk`. - static sol::object load(sol::this_state ts, sol::this_environment this_env, const std::string& config_lua) + // Lua API: Function. Table: mod_settings. Name: load. Param: configFilePath: string: Path, relative to the mod's + // folder, of the `config.lua` that returns `config` and `configDesc`. Returns: table: A live read/write proxy over + // the mod's config. Index it to read a setting and assign to write one. Registers the mod's settings under the Mods + // tab of the in-game Options menu. Also manages the mod's `.cfg` file, setting default values for new options and + // loading values saved to it by users. When using this, your mod does not need to depend on or use `Chalk`. + static sol::object load(sol::this_state ts, sol::this_environment this_env, const std::string& config_file_path) { if (!this_env) { @@ -1329,7 +1323,7 @@ namespace big::mod_settings auto& cf = module->m_data.m_config_files.emplace_back(std::make_unique(cfg_path, true, guid)); const std::string mod_folder = env["_PLUGIN"]["plugins_mod_folder_path"]; - const std::string config_lua_path = mod_folder + "/" + config_lua; + const std::string config_lua_path = mod_folder + "/" + config_file_path; sol::load_result loaded = state.load_file(config_lua_path); if (!loaded.valid()) @@ -1784,9 +1778,9 @@ namespace big::mod_settings #pragma region Opt-out and API registration // Lua API: Function. Table: mod_settings. Name: opt_out. Param: description: string: Optional. A plain string or a - // localization table `{ en = "...", de = "..." }` shown in place of the generic opt-out note. Excludes the calling - // mod from the in-game menu: it stays listed but greyed out and cannot be opened. Works with Chalk or - // rom.mod_settings.load. + // localization table `{ en = "...", de = "..." }` shown in place of the generic note when the mod's disabled row is + // hovered. Excludes the calling mod from the in-game mod settings menu: it stays listed but will be greyed out and + // cannot be opened. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. static void opt_out(sol::this_environment this_env, sol::object description) { // Keyed by the calling mod's guid (which matches its config-file stem), so the menu can grey the matching row diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index ebb16d8..ade9dcd 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2157,8 +2157,8 @@ namespace big::mod_settings // Bad numeric input simply keeps the previous value because set_serialized_value validates before saving. g_edit_entry->set_serialized_value(g_edit_buffer); - // Clamp/snap a bounded freetext number to the stepper grid: [min, max] and min + k*step. - // set_serialized_value above already parsed/validated the number. + // Clamp/snap the typed number to any declared min/max/step. A fully bounded number renders as a slider, + // so this covers partially bounded or stepped ones. set_serialized_value above already parsed it. if (g_edit_entry->type() == typeid(double)) { const auto meta = resolved_metadata(g_edit_entry->m_config_file->m_config_file_stem_as_str, @@ -2843,7 +2843,7 @@ namespace big::mod_settings const bool is_bool = vv.type == virtual_value::kind::boolean; const bool is_number = vv.type == virtual_value::kind::number; const double step = (vmeta && vmeta->has_step) ? vmeta->step : 1.0; - const bool is_stepper = !is_enum && is_number && vmeta && vmeta->has_min && vmeta->has_max && !vmeta->freetext; + const bool is_stepper = !is_enum && is_number && vmeta && vmeta->has_min && vmeta->has_max; std::string vv_serialized; switch (vv.type) @@ -3057,10 +3057,10 @@ namespace big::mod_settings const std::string mname = meta ? resolve_localized(meta->name) : std::string{}; const std::string label = escape_markup(!mname.empty() ? mname : key_to_display(key)); - // Enums use num-boxes, bounded numbers use sliders unless `freetext` is set, and everything else uses freetext. + // Enums use num-boxes, bounded numbers use sliders, and everything else uses freetext. const bool is_number = entry->type() == typeid(double); const bool is_enum = meta && !meta->values.empty(); - const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max && !meta->freetext; + const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max; const double step = (meta && meta->has_step) ? meta->step : 1.0; // Enum option lists are resolved once so the widget and PanelRow share them. diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index b137e3c..09e83c9 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -75,12 +75,11 @@ namespace big::mod_settings std::vector labels; bool has_order = false; - double order = 0.0; // author-declared sort key (lower first), unset -> alphabetical by display name + double order = 0.0; // author-declared sort key (lowest first), unset -> alphabetical by display name bool hidden = false; // author asked to omit this row entirely bool disabled = false; // render greyed and non-interactive but still visible (may be dynamic) bool restart_required = false; // change only takes effect after a game restart - bool freetext = false; // force a bounded number to freetext entry (not the stepper) editable_context context = editable_context::any; From 6428a75be750d314374db1314941acc4a5cd8114 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:15:34 +0100 Subject: [PATCH 080/100] Align concepts --- docs/mod_settings/README.md | 4 +- docs/mod_settings/config_schema.lua | 8 ++-- src/hades2/mod_settings/mod_settings.cpp | 53 +++++++++++------------- 3 files changed, 30 insertions(+), 35 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index d8a257e..ec84f7c 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -33,7 +33,7 @@ below. Two other kinds of `configDesc` entry have their own fields and sections: | `description` | string \| localization table \| callback | Help text shown in the description box at the bottom of the screen while the row is highlighted. Keep it to ~450 characters. | | `min`/`max` | number \| callback | Numeric bounds. If both are present the input will turn into a slider. | | `step` | number \| callback | Slider/number step size (default 1). Will clamp user input automatically. | -| `values` | array \| callback | Enum: the values stored in the `.cfg` file. If present, the input will turn into a cycler. | +| `values` | array \| callback | Enum: the values stored in the `.cfg` file. If present, the input will turn into a selector. | | `labels` | array of (string \| localization table) \| callback | Display labels to show instead of the underlying `values` in the mod menu. Keep each to ~20 characters. | | `order` | number \| callback | Sort key for custom ordering config entries in the menu, lowest first. Rows carrying an `order` are listed above those without one. When omitted, rows are sorted alphabetically by their `displayName`. | | `hidden` | boolean | Hide the setting from the menu entirely. Static only - use `disabled` for rows that change state while the menu is open. | @@ -120,7 +120,7 @@ A virtual row is either **read-only** or **interactive**: - **Interactive:** give it `get` (reads the current value) and `set` (writes the edited value). The widget is inferred from `get()`'s value and the metadata, exactly like a config setting is inferred from its config value: a **boolean** is a toggle, a **number** with `min`+`max` is a slider (otherwise a freetext field), - and any type with a `values` list is an **enum picker**. + and any type with a `values` list is an **enum selector**. Interactive rows also support `disabled`, `disabledDescription`, `editableContext`, `showAsPercentage`/ `isPercentage`, and (for enums) `labels` - the same as config settings. `get`/`set`/`text` and the metadata diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index e72c3a8..6f8da46 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -36,7 +36,7 @@ --- Describes how a config option appears in the in-game mod settings menu. Every field is optional. The --- widget type is inferred from the setting's config value (a boolean becomes a toggle; a number with `min` ---- and `max` becomes a slider; a value with `values` becomes a cycler; anything else is a free-text field). +--- and `max` becomes a slider; a value with `values` becomes a selector; anything else is a free-text field). ---@class (exact) mod_settings.setting_description --- Row label. Defaults to a prettified version of the config key (e.g. `myCool_Setting` -> "My Cool Setting"). --- Recommended to keep to about 35 characters so it leaves enough space for the value shown to its right. @@ -81,7 +81,7 @@ --- `showAsPercentage` when using this. ---@field isPercentage? boolean --- Enum options: the values actually stored in the .cfg file. ---- Providing this makes the setting a cycler over these options. +--- Providing this makes the setting a selector over these options. ---@field values? (string | number | boolean)[] | fun(): (string | number | boolean)[] --- Display labels to show instead of the underlying `values` in the mod menu (same order, same number of --- entries). Each label may be a localization table. Recommended to keep each to about 20 characters. @@ -116,7 +116,7 @@ --- - READ-ONLY: give it `text` (a string, or a function returning one). --- - INTERACTIVE: give it `get` (read) and `set` (write). The widget is inferred from get()'s value and the --- metadata, exactly like a config setting is inferred from its config value: a boolean is a toggle, a number ---- with `min`+`max` is a slider (otherwise a freetext field), and any type with `values` is an enum picker. If +--- with `min`+`max` is a slider (otherwise a freetext field), and any type with `values` is an enum selector. If --- get() can return nil at build time, force the widget with `type`. Give it a `default` to have the menu Reset --- restore it. --- `get`/`set`/`text` and the metadata fields (displayName/description/values/min/max/step/labels) may all be @@ -169,7 +169,7 @@ --- `showAsPercentage` when using this. ---@field isPercentage? boolean --- Enum options: the values actually stored in the .cfg file. ---- Providing this makes the setting a cycler over these options. +--- Providing this makes the setting a selector over these options. ---@field values? (string | number | boolean)[] | fun(): (string | number | boolean)[] --- Display labels to show instead of the underlying `values` in the mod menu (same order, same number of --- entries). Each label may be a localization table. Recommended to keep each to about 20 characters. diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index ade9dcd..7a5eb32 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -287,7 +287,7 @@ namespace big::mod_settings static gui_component_ctor_fn g_textbox_ctor = nullptr; static slider_defaults_fn g_slider_defaults = nullptr; static slider_set_fraction_fn g_slider_set_fraction = nullptr; - static std::uintptr_t g_slider_vtable = 0; // runtime + static std::uintptr_t g_slider_vtable = 0; // resolved slider vftable address // A patched copy of the slider vtable (built in set_up_hooks) whose GetArea/GetScreenArea slots return a one-row // hit rect (see row_bounded_area), replacing the native ones that union the slider's sub-components into a // screen-spanning rect. 128 slots comfortably covers the class's virtual table. @@ -295,14 +295,14 @@ namespace big::mod_settings // The highest slot we override or copy through is SetLocation at +0x180, so keep the buffer big enough for it. static_assert(0x1'80 / sizeof(std::uintptr_t) < slider_vtable_slot_count, "vtable copy buffer too small for the highest patched slot"); static std::uintptr_t g_slider_vtable_copy[slider_vtable_slot_count] = {}; - static std::uintptr_t g_slider_vtable_patched = 0; // runtime + static std::uintptr_t g_slider_vtable_patched = 0; // address of the patched copy above // A patched copy of the GUIComponentButton vtable (built lazily in install_wide_button_nav_rect from the first action // button's vtable) whose GetArea/GetScreenArea slots return the same wide one-row rect (row_bounded_area), so a // centre-column action button is reachable by the vertical spatial nav. Every other button row keeps the native // vtable. static std::uintptr_t g_button_vtable_copy[slider_vtable_slot_count] = {}; - static std::uintptr_t g_button_vtable_patched = 0; // runtime + static std::uintptr_t g_button_vtable_patched = 0; // address of the patched copy above static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) static set_mouse_over_fn g_set_mouse_over = nullptr; // MenuScreen::SetMouseOver (highlight + select a row) static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) @@ -335,7 +335,7 @@ namespace big::mod_settings static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column static constexpr float value_text_offset_x = 15.0f; // right-justify the value, right edge aligns with the toggle's static constexpr float numbox_location_x = 1365.0f; // native OptionNumBox X (box + arrows clear the scrollbar) - static constexpr float slider_location_x = 1330.0f; // native OptionSlider X (bar + value clear the scrollbar + static constexpr float slider_location_x = 1330.0f; // native OptionSlider X (bar + value clear the scrollbar) // template's label offset puts the name in the option-name column). static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) static constexpr float row_base_y = 300.0f; // first row's Y - matches the vanilla option templates @@ -406,7 +406,7 @@ namespace big::mod_settings bool show_as_percentage = false; bool is_percentage = false; - // Enum cycler, rendered as a native number box whose value text is overridden to the label. + // Enum row, rendered as a native num-box whose value text is overridden to the label. bool is_enum = false; std::vector enum_values; std::vector enum_labels; @@ -522,7 +522,7 @@ namespace big::mod_settings } // The mod's Thunderstore manifest description, shown in the description box while its row in the mod list is - // highlighted. Empty when no loaded module matches the stem. + // highlighted. static std::string mod_description_from_stem(const std::string& stem) { if (!big::g_lua_manager) @@ -543,8 +543,8 @@ namespace big::mod_settings // Description-box note for a mod that opted out of the in-game settings menu. static std::string opt_out_note() { - return "This mod opted out of the in-game settings menu. See the mod's own description for how " - "to configure it, if applicable."; + return "This mod opted out of the in-game settings menu. Check the mod page for how to " + "configure it, if applicable."; } static std::string resolve_localized(const localized_text& t); // defined below @@ -557,7 +557,7 @@ namespace big::mod_settings } // Escapes the characters GUIComponentTextBox::Parse treats as markup, so arbitrary user text renders verbatim. - // The parser reads '\' as an escape lead that consumes the following word ("D:\Program..." -> "D: ...") and '[' ']' + // The parser reads '\' as an escape lead that consumes the following word ("C:\Program..." -> "C: ...") and '[' ']' // as inline-tag delimiters whose contents are dropped ("[deprecated] x" -> " x"). Backslash must be escaped first. // '{' and '@' are also markup leads but have no literal escape and do not eat surrounding characters, so are left. static std::string escape_markup(const std::string& text) @@ -643,7 +643,7 @@ namespace big::mod_settings case 'W': case '@': case '%': return 1.5f; // wide glyphs - default: return 1.0f; // medium (digits, most letters) + default: return 1.0f; } } @@ -763,7 +763,6 @@ namespace big::mod_settings button->m_hidden = false; button->m_is_useable = true; - // Point the button's localization id at "Mods" so language changes keep rendering the raw key instead of "Editor". if (g_hash_lookup) { HashGuid id{}; @@ -771,7 +770,6 @@ namespace big::mod_settings *reinterpret_cast(reinterpret_cast(button) + sgg::gui_component_button_display_name_id_offset) = id.m_id; } - // Apply the label now because UseDefaultText only re-derives on the next localization pass. if (g_set_label) { g_set_label(button, "Mods"); @@ -797,9 +795,7 @@ namespace big::mod_settings // GUI objects are allocated and freed through the GAME's CRT, never H2M's: H2M is /MT while the game is /MD against // ucrtbase, and the engine frees anything it owns (removed screens, a slider's sub-components, tf_new_internal - // blocks) with ucrtbase's _aligned_free. Crossing the boundary either way hands a heap a block it never owned. - // The deleting destructor is always called with flags = 0 for the same reason: flags = 1 routes to operator delete - // -> free() on an _aligned_malloc block, which the engine never does either. + // blocks) with ucrtbase's _aligned_free. using aligned_malloc_fn = void*(__cdecl*)(std::size_t, std::size_t); using aligned_free_fn = void(__cdecl*)(void*); @@ -808,7 +804,7 @@ namespace big::mod_settings static void* game_alloc(std::size_t size) { - return g_game_aligned_malloc ? g_game_aligned_malloc(size, 8) : nullptr; // alignment 8 matches the engine + return g_game_aligned_malloc ? g_game_aligned_malloc(size, 8) : nullptr; } static void game_free(void* block) @@ -875,7 +871,7 @@ namespace big::mod_settings std::memcpy(def + def_press_sound, def + src, sound_cue_size); } - // Dims a row's def text colours (both normal and selected) so a disabled row reads as greyed out and does not + // Dims a row's def text colours so a disabled row reads as greyed out and does not // recolour on hover. Must be applied before SetupComponent so the change reaches the text box. static void set_def_text_grey(GUIComponent* row) { @@ -949,7 +945,6 @@ namespace big::mod_settings } } - // A plain left-justified text row. Disabled rows are greyed and hard-disabled by default. static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true, bool no_hover_highlight = false) { auto* row = create_button(screen); @@ -966,7 +961,7 @@ namespace big::mod_settings *reinterpret_cast(def + def_add_text_area) = 1; // hit area follows the text *reinterpret_cast(def + def_use_text_area) = 0; // (union with the empty graphic area) *reinterpret_cast(def + def_graphic) = 0; // no button background - *reinterpret_cast(def + def_selected_graphic) = 0; // no + *reinterpret_cast(def + def_selected_graphic) = 0; // no highlight box (text recolours instead) *reinterpret_cast(def + def_alternate_graphic) = 0; *reinterpret_cast(def + def_width) = 0.0f; // let the text drive the area *reinterpret_cast(def + def_height) = 0.0f; @@ -1071,7 +1066,7 @@ namespace big::mod_settings return row; } - // A centered native button row for actions like Apply/Reset, visually distinct from plain-text setting rows. + // A centered native button row for actions. static void install_wide_button_nav_rect(GUIComponent* row); // defined below (near row_bounded_area) static GUIComponent* make_button_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) @@ -2839,10 +2834,10 @@ namespace big::mod_settings default: break; } } - const bool is_enum = vmeta && !vmeta->values.empty(); - const bool is_bool = vv.type == virtual_value::kind::boolean; - const bool is_number = vv.type == virtual_value::kind::number; - const double step = (vmeta && vmeta->has_step) ? vmeta->step : 1.0; + const bool is_enum = vmeta && !vmeta->values.empty(); + const bool is_bool = vv.type == virtual_value::kind::boolean; + const bool is_number = vv.type == virtual_value::kind::number; + const double step = (vmeta && vmeta->has_step) ? vmeta->step : 1.0; const bool is_stepper = !is_enum && is_number && vmeta && vmeta->has_min && vmeta->has_max; std::string vv_serialized; @@ -3200,7 +3195,7 @@ namespace big::mod_settings } else if (is_stepper) { - // Bounded numbers use sliders, falling back to a number-box if the slider cannot be built. + // Bounded numbers use sliders, falling back to a num-box if the slider cannot be built. row = make_slider_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), meta->show_as_percentage, meta->is_percentage, disabled); if (row) { @@ -4435,7 +4430,7 @@ namespace big::mod_settings return result; } - // Value-change hook for our native number-box rows, filtered because it also fires for native settings num-boxes. + // Value-change hook for our native num-box rows, filtered because it also fires for native settings num-boxes. static void hook_GUIComponentNumBox_SetNumberValue(void* self, float value, bool notify) { big::g_hooking->get_original()(self, value, notify); @@ -4658,7 +4653,7 @@ namespace big::mod_settings { auto* entry = matched_row.entry; - // Boolean settings toggle in place. Other types open a freetext editor. Number-box rows are + // Boolean settings toggle in place. Other types open a freetext editor. Num-box rows are // GUIComponentNumBox, so their clicks never reach this hook. if (entry && entry->type() == typeid(bool)) { @@ -5119,7 +5114,7 @@ namespace big::mod_settings g_button_dtor = big::hades2_symbol_to_address["sgg::GUIComponentButton::~GUIComponentButton"].as_func(); g_disable = big::hades2_symbol_to_address["sgg::GUIComponentButton::Disable"].as_func(); - // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the number-box stepper). + // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the num-box stepper). // SetFraction is both the initial set and the drag hook (installed below). The slider vtable is resolved by name (RVA // fallback) once the build is verified. g_gui_component_ctor = big::hades2_symbol_to_address["sgg::GUIComponent::GUIComponent"].as_func(); @@ -5263,7 +5258,7 @@ namespace big::mod_settings set_number_value); // Optional: persists user drags on our slider rows (filtered to our rows via find_row, so it is a no-op for the - // native audio sliders). If absent, bounded numbers render as the number-box stepper. + // native audio sliders). If absent, bounded numbers render as the num-box stepper. if (slider_set_fraction) { static auto set_fraction_hook = hooking::detour_hook_helper::add_queue("sgg::GUIComponentSlider::SetFraction", slider_set_fraction); From 515a8c8891fa17fa2dbf9487f1171b6435e257b1 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:33:16 +0100 Subject: [PATCH 081/100] Allow groups to be disabled dynamically --- docs/mod_settings/README.md | 5 +- docs/mod_settings/config_schema.lua | 14 +++- src/hades2/mod_settings/config_api.cpp | 76 ++++++++++++++++- src/hades2/mod_settings/mod_settings.cpp | 100 +++++++++++++++++++---- src/hades2/mod_settings/mod_settings.hpp | 12 ++- 5 files changed, 183 insertions(+), 24 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index ec84f7c..a3e9615 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -40,7 +40,7 @@ below. Two other kinds of `configDesc` entry have their own fields and sections: | `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. | | `disabledDescription` | string \| localization table \| callback | Description shown in place of `description` while the setting is greyed by its own `disabled` field, to explain why. Falls back to `description` when omitted. | | `restartRequired` | boolean | Force the user to restart the game after existing the mod menu if this setting was changed. | -| `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict where the row can be edited: `"any"` (default), `"mainMenu"` (only from the main menu), `"inSave"` (only while a save is loaded), or `"inHub"` (only in the Crossroads). Outside of the allowed context the row shows as disabled. Restrict this if the mod or game would break if the setting is edited in the wrong context. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | +| `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict where the row can be edited: `"any"` (default), `"mainMenu"` (only from the main menu), `"inSave"` (only while a save is loaded), or `"inHub"` (only in the Crossroads). Outside of the allowed context the row shows as disabled. Restrict this if the mod or game would break if the setting is edited in the wrong context. Can also be set on a whole menu category, which restricts everything inside it. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | | `showAsPercentage` | boolean | Append "%" to the value. Usually used for min/max restricted number fields. | | `isPercentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". You don't need `showAsPercentage` when using this. | | `onChanged` | `fun(key, new_value)` | Called after the setting is changed through the menu. | @@ -58,7 +58,8 @@ can be independent: category. It is a string for a single level, or an array for a nested path. This works for flat *and* nested config keys, and doesn't change where the value is stored in the .cfg file. - Declare menu categories that do **not** exist as config sections in a top-level **`groups`** table (keyed by the id - used in a `group`), each with an optional `displayName`, `description`, `order`, and further nested `groups`. + used in a `group`), each with an optional `displayName`, `description`, `order`, `disabled`, + `disabledDescription`, `editableContext`, and further nested `groups`. This lets you keep a flat config but present any grouping you like, or re-nest an already-nested config another way. diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 6f8da46..0509e2b 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -24,12 +24,22 @@ --- be presented under an arbitrary menu tree. Only needed for categories that are not config sections already. ---@class (exact) mod_settings.menu_group --- Category label shown on its drill-down row. Defaults to a prettified version of the group's key. ----@field displayName? mod_settings.localized_string +---@field displayName? mod_settings.dynamic_string --- Help text shown while the category's row is highlighted. ----@field description? mod_settings.localized_string +---@field description? mod_settings.dynamic_string --- Sort key among sibling categories/rows, lowest first. Entries with an `order` are listed above those without --- one, which are sorted alphabetically by their displayName. ---@field order? number +--- Grey the group out (shown read-only, cannot be entered) while this is true. Updates live while the menu is +--- open (e.g. grey a group unless a toggle is enabled). +---@field disabled? mod_settings.dynamic_boolean +--- Description shown in place of `description` while the setting is greyed by its own `disabled` field, to +--- explain why it is unavailable. Defaults to the normal `description` when omitted. +---@field disabledDescription? mod_settings.dynamic_string +--- Restrict where this category can be entered: main menu, in a save, in the Crossroads, or anywhere (default +--- "any"). Outside the allowed context the row is greyed and cannot be opened, which restricts everything inside +--- it too - rows in the category do not need to repeat it, but may restrict themselves further. +---@field editableContext? "any" | "mainMenu" | "inSave" | "inHub" --- Further nested sub-categories, keyed by their id (referenced as later path segments in a `group`). ---@field groups? table diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index e7c2b49..3f7bec4 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -145,6 +145,7 @@ namespace big::mod_settings #pragma region Config.lua parsing helpers static std::string serialize_option(const sol::object& v); // defined below. + static editable_context parse_editable_context(const sol::object& o, editable_context fallback); // defined below. // Parses a user-facing string field: either a plain scalar (stored under the empty key) or a localization table // keyed by language folder codes, e.g. { en = "...", ["zh-TW"] = "..." }. Empty/absent yields an empty map. @@ -252,8 +253,8 @@ namespace big::mod_settings } // Recursively parses a configDesc `groups` table into menu_group nodes. Each key is a group id (used in a `group` - // path), its value a table of optional `displayName`/`description`, `order`, and nested `groups`. Non-tables are - // skipped. Siblings sort by `order` then id (Lua declaration order is lost). + // path), its value a table of optional `displayName`/`description`/`disabled`/`disabledDescription`, `order`, and + // nested `groups`. Non-tables are skipped. Siblings sort by `order` then id (Lua declaration order is lost). static std::vector parse_menu_groups(const sol::object& groups_obj) { std::vector out; @@ -276,13 +277,30 @@ namespace big::mod_settings LOG(WARNING) << "[mod_settings] ignoring menu group id '" << g.id << "' containing '.', which is reserved as the menu-path separator (nest via a `groups` sub-table instead)."; return; } - g.name = parse_localized(gt["displayName"]); - g.description = parse_localized(gt["description"]); + g.name = parse_localized(gt["displayName"]); + g.description = parse_localized(gt["description"]); + g.disabled_description = parse_localized(gt["disabledDescription"]); if (sol::object order = gt["order"]; order.get_type() == sol::type::number) { g.has_order = true; g.order = order.as(); } + if (sol::object d = gt["disabled"]; d.is()) + { + g.disabled = d.as(); + } + g.context = parse_editable_context(gt["editableContext"], editable_context::any); + + // A field written as a Lua function is skipped by the type-guarded reads above and re-evaluated at + // render by resolve_menu_group. + for (const char* field : {"displayName", "description", "disabledDescription", "disabled"}) + { + if (gt[field].get_type() == sol::type::function) + { + g.has_dynamic = true; + break; + } + } g.children = parse_menu_groups(gt["groups"]); out.push_back(std::move(g)); }); @@ -1433,6 +1451,56 @@ namespace big::mod_settings return m; } + std::optional resolve_menu_group(const std::string& guid, const std::vector& path) + { + if (!big::g_lua_manager || path.empty()) + { + return std::nullopt; + } + sol::state_view state = big::g_lua_manager->lua_state(); + const sol::object root = stored_descriptions(state, guid); + if (!root.is()) + { + return std::nullopt; + } + + // Walk the declaration tree: root `groups` for the first id, then each node's own `groups` for the next. + sol::object node = root.as()["groups"]; + sol::table entry; + for (std::size_t i = 0; i < path.size(); ++i) + { + if (!node.is()) + { + return std::nullopt; + } + sol::object child = node.as()[path[i]]; + if (!child.is()) + { + return std::nullopt; + } + entry = child.as(); + node = entry["groups"]; + } + + const sol::table r = resolve_description(state, entry, guid); + menu_group g; + g.id = path.back(); + g.name = parse_localized(r["displayName"]); + g.description = parse_localized(r["description"]); + g.disabled_description = parse_localized(r["disabledDescription"]); + if (sol::object order = r["order"]; order.get_type() == sol::type::number) + { + g.has_order = true; + g.order = order.as(); + } + if (sol::object d = r["disabled"]; d.is()) + { + g.disabled = d.as(); + } + g.context = parse_editable_context(r["editableContext"], editable_context::any); + return g; + } + // True when the game is in the hub (the Crossroads), i.e. the game Lua global `CurrentHubRoom` is non-nil. Reads // the game's Lua state directly, so it must be called on the game thread while the state is alive. bool game_is_in_hub() diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 7a5eb32..54fa6b0 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2357,6 +2357,25 @@ namespace big::mod_settings return p; } + // The id chain of an author group menu path, i.e. its segments after the root section. Empty for the root itself. + static std::vector author_group_path(const std::string& menu_path) + { + std::vector out; + const std::string prefix = std::string(root_section) + "."; + if (menu_path.rfind(prefix, 0) != 0) + { + return out; + } + std::string rest = menu_path.substr(prefix.size()); + while (!rest.empty()) + { + const auto dot = rest.find('.'); + out.push_back(rest.substr(0, dot)); + rest = (dot == std::string::npos) ? std::string{} : rest.substr(dot + 1); + } + return out; + } + // Finds an author-declared menu group by its full menu path. static const menu_group* find_author_group(const std::vector& tree, const std::string& menu_path) { @@ -2446,6 +2465,9 @@ namespace big::mod_settings bool is_author_group = false; // group only: declared in configDesc `groups` (not a config section) localized_text author_name; // author-group display name (is_author_group only) localized_text author_description; // author-group description (is_author_group only) + localized_text author_disabled_description; // shown instead of the description while disabled + bool author_disabled = false; // author-group `disabled` (already resolved if dynamic) + editable_context group_context = editable_context::any; // group only: when the page may be entered bool has_order = false; double order = 0.0; std::string sort_name; // resolved display name, the alphabetical fallback sort key @@ -2519,23 +2541,45 @@ namespace big::mod_settings g.is_author_group = true; g.author_name = ag->name; g.author_description = ag->description; - g.sort_name = resolve_localized(ag->name); + g.author_disabled_description = ag->disabled_description; + g.author_disabled = ag->disabled; + g.group_context = ag->context; if (ag->has_order) { g.has_order = true; g.order = ag->order; } + + // A dynamic field is skipped at load, so re-evaluate the whole declaration against the current state. + if (ag->has_dynamic) + { + g_view_has_dynamic = true; + if (const auto live = resolve_menu_group(stem, author_group_path(child_path))) + { + g.author_name = live->name; + g.author_description = live->description; + g.author_disabled_description = live->disabled_description; + g.author_disabled = live->disabled; + g.group_context = live->context; + } + } + g.sort_name = resolve_localized(g.author_name); } else if (const auto meta = resolved_metadata(stem, section, g.key); meta) { g.sort_name = resolve_localized(meta->name); - // Config-derived group metadata comes from configDesc.
.. + // Config-derived group metadata comes from configDesc.
., whose desc table doubles as + // its children's descriptions, so defer to a real config child of the same name. if (meta->has_order && !config_child_exists(view_cfg, child_path, "order")) { g.has_order = true; g.order = meta->order; } + if (!config_child_exists(view_cfg, child_path, "editableContext")) + { + g.group_context = meta->context; + } } if (g.sort_name.empty()) { @@ -2989,18 +3033,22 @@ namespace big::mod_settings { std::string glabel; std::string gdescription; + std::string gdisabled_description; + bool group_disabled = false; if (it.is_author_group) { const std::string gname = resolve_localized(it.author_name); glabel = escape_markup(!gname.empty() ? gname : key_to_display(it.key)); gdescription = resolve_localized(it.author_description); + gdisabled_description = resolve_localized(it.author_disabled_description); + group_disabled = it.author_disabled; } else { auto gmeta = resolved_metadata(stem, section, it.key); - // A group's desc table doubles as its children's descriptions. If displayName/description/hidden is - // one of the group's own config children, defer to that child. + // A group's desc table doubles as its children's descriptions. If displayName/description/hidden or + // disabled is one of the group's own config children, defer to that child. if (gmeta && view_cfg) { if (config_child_exists(view_cfg, it.child_section, "displayName")) @@ -3015,6 +3063,10 @@ namespace big::mod_settings { gmeta->hidden = false; } + if (config_child_exists(view_cfg, it.child_section, "disabled")) + { + gmeta->disabled = false; + } } if (gmeta && gmeta->hidden) @@ -3024,14 +3076,32 @@ namespace big::mod_settings const std::string gname = gmeta ? resolve_localized(gmeta->name) : std::string{}; glabel = escape_markup(!gname.empty() ? gname : key_to_display(it.key)); gdescription = gmeta ? resolve_localized(gmeta->description) : std::string{}; + gdisabled_description = gmeta ? resolve_localized(gmeta->disabled_description) : std::string{}; + group_disabled = gmeta && gmeta->disabled; } - if (auto* row = make_text_row(screen, glabel.c_str(), disabled)) + // A category the current context does not allow is greyed and cannot be entered, which restricts every + // row inside it without them having to repeat the restriction. + const bool ctx_blocked = is_context_restricted(it.group_context); + + // Greyed like any other row: author-disabled or context-blocked keeps it hoverable so the note can be + // read, while the whole mod being disabled makes it fully inert. + const bool greyed = disabled || group_disabled || ctx_blocked; + if (auto* row = make_text_row(screen, glabel.c_str(), greyed, /*block_input*/ disabled)) { PanelRow pr{row, RowKind::group, stem, {}}; - pr.disabled = disabled; + pr.disabled = greyed; // blocks the drill-in in the click handler pr.target_section = it.child_section; - pr.description = gdescription; + + // Context-blocked rows show the scenario note first. Author-disabled rows show disabledDescription. + if (ctx_blocked) + { + pr.description = note_then_description(context_note(it.group_context), gdescription); + } + else + { + pr.description = (group_disabled && !gdisabled_description.empty()) ? gdisabled_description : gdescription; + } g_rows.push_back(std::move(pr)); } continue; @@ -5160,12 +5230,14 @@ namespace big::mod_settings missing.push_back("ucrtbase.dll _aligned_malloc/_aligned_free (the game's CRT heap)"); } - // The hardcoded RVAs and struct offsets above are valid only for the Ship build they were captured against, and - // unlike the name-resolved symbols they do NOT auto-adapt - a game update could move them and crash the options - // screen. So gate the menu on the exact build via its PDB GUID: after an update the GUID no longer matches and - // the tab is cleanly skipped (the rom.mod_settings Lua API is unaffected) until Hell2Modding is updated. - static constexpr const char* validated_pdb_guid = "744ea71c-2c21-4b40-a6c486d1fa6647da"; - const bool build_validated = big::hades2_pdb_guid == validated_pdb_guid; + // The hardcoded RVAs and struct offsets above are valid only for the build they were captured against. + // We gate the (attempted) creation of the menu itself on a valid GUID to not crash the game unnecessarily. + // The config API itself works regardless + static constexpr const char* validated_pdb_guids[] = { + "744ea71c-2c21-4b40-a6c486d1fa6647da", // Ship, 2026-08-04 + }; + const bool build_validated = std::find(std::begin(validated_pdb_guids), std::end(validated_pdb_guids), big::hades2_pdb_guid) + != std::end(validated_pdb_guids); // Secondary sanity check on top of the GUID allow-list: the anchor (button ctor) must sit at its known module // RVA. A matching GUID already implies this, so a failure here means the PDB and the loaded exe disagree (e.g. @@ -5197,7 +5269,7 @@ namespace big::mod_settings { detail += "\n - game build not validated for this Hell2Modding version (PDB GUID '"; detail += big::hades2_pdb_guid.empty() ? "" : big::hades2_pdb_guid; - detail += "'). The game likely updated; update validated_pdb_guid to this GUID after re-validating the " + detail += "'). The game likely updated; add this GUID to validated_pdb_guids after re-validating the " "engine offsets/RVAs against the new Ship build."; } if (!build_matches) diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 09e83c9..a52b094 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -43,14 +43,22 @@ namespace big::mod_settings std::string id; localized_text name; localized_text description; - bool has_order = false; - double order = 0.0; + localized_text disabled_description; + bool has_order = false; + double order = 0.0; + bool disabled = false; + editable_context context = editable_context::any; + bool has_dynamic = false; std::vector children; }; // The author-declared menu group tree for mod `guid`, empty when none was declared. std::vector mod_menu_groups(const std::string& guid); + // Re-resolves one author-declared group's dynamic fields against the current game state. `path` is its id chain + // under the root configDesc `groups` (e.g. { "debugging", "logging" }). + std::optional resolve_menu_group(const std::string& guid, const std::vector& path); + // Author-declared metadata for one setting, from its config.lua description table. Only settings described with a // rich table get an entry - the rest fall back to type-based rendering. All fields are optional (see the has_*). struct setting_metadata From 86e1b2cd508f46a78f1d97bc710ab5d157093e20 Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:34:39 +0100 Subject: [PATCH 082/100] Added hold-to-move for sliders --- src/hades2/mod_settings/config_api.cpp | 6 +- src/hades2/mod_settings/mod_settings.cpp | 124 +++++++++++++++-------- src/hades2/mod_settings/sgg_gui.hpp | 2 +- 3 files changed, 88 insertions(+), 44 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 3f7bec4..5918a00 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -318,7 +318,7 @@ namespace big::mod_settings return flag.is() && flag.as(); } - // Parses an `editableContext` field ("any"/"mainMenu"/"inSave"/"inHub") returns `fallback` for anything else. + // Parses an `editableContext` field ("any"/"mainMenu"/"inSave"/"inHub"), returning `fallback` for anything else. // Shared by setting metadata and action buttons. static editable_context parse_editable_context(const sol::object& o, editable_context fallback) { @@ -359,7 +359,7 @@ namespace big::mod_settings } } - // Reads the array part of a Lua list table (ipairs order) applying `transform` to each element. + // Reads the array part of a Lua list table (ipairs order), applying `transform` to each element. template static void read_list(const sol::object& obj, std::vector& out, Transform transform) { @@ -1330,7 +1330,7 @@ namespace big::mod_settings } const std::string guid = module->guid(); - // .cfg path = rom.path.combine(rom.paths.config(), guid ".cfg") - identical to the path Chalk used, so an + // .cfg path = rom.path.combine(rom.paths.config(), guid .. ".cfg") - identical to the path Chalk used, so an // existing .cfg is reused. sol::table rom = env["rom"]; sol::function path_combine = rom["path"]["combine"]; diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 54fa6b0..8fc34a9 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -180,6 +180,11 @@ namespace big::mod_settings // GUIComponentSlider has no Draw-time highlight gate (unlike GUIComponentButton, whose Draw re-derives it from // mForceSelected/owner->mSelectedComponent), so the focus look tracks its own mFocused bool. static constexpr std::size_t slider_focused_offset = 0x5'48; // GUIComponentSlider::mFocused (bool) + + // Held-input auto-repeat for slider rows, matching the values the native num-box constructor writes into + // mRepeatDelay/mRepeatInterval: the press steps once immediately, then holding repeats at 20 Hz after a pause. + static constexpr float slider_repeat_delay = 0.6f; + static constexpr float slider_repeat_interval = 0.05f; static constexpr std::size_t textbox_use_selected_color_off = 0x5'52; // GUIComponentTextBox::mUseSelectedTextColor static constexpr std::size_t vtable_on_mouse_off_offset = 0x00'60; // GUIComponent::OnMouseOff slot static constexpr std::size_t vtable_on_unselected_offset = 0x00'88; // GUIComponent::OnUnselected slot @@ -313,6 +318,8 @@ namespace big::mod_settings static mouse_button_down_fn g_mouse_button_down = nullptr; // true while a mouse button is held (active drag detect) static input_dir_pressed_fn g_input_was_left_pressed = nullptr; // left/decrease press edge (dpad, arrow, stick) static input_dir_pressed_fn g_input_was_right_pressed = nullptr; // right/increase press edge + static input_dir_pressed_fn g_input_is_left_pressed = nullptr; // left/decrease held (level, not just the edge) + static input_dir_pressed_fn g_input_is_right_pressed = nullptr; // right/increase held static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B/keyboard Esc) static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A/Enter) static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) @@ -333,10 +340,9 @@ namespace big::mod_settings // = (index - pageStart) * row_pitch + row_base_y + ScreenCenterOffsetY, and X = the row's own location. static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column - static constexpr float value_text_offset_x = 15.0f; // right-justify the value, right edge aligns with the toggle's + static constexpr float value_text_offset_x = 15.0f; // right-justify the value, aligning it with the toggle column static constexpr float numbox_location_x = 1365.0f; // native OptionNumBox X (box + arrows clear the scrollbar) static constexpr float slider_location_x = 1330.0f; // native OptionSlider X (bar + value clear the scrollbar) - // template's label offset puts the name in the option-name column). static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) static constexpr float row_base_y = 300.0f; // first row's Y - matches the vanilla option templates static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) @@ -1267,9 +1273,6 @@ namespace big::mod_settings set_sso_string(static_cast(right_arrow) + gui_component_name_offset, "OptionNumBoxRightArrow"); } - // Integer box when the bounds and step are all whole (shows "3" not "3.0" and uses the discrete single-step - // path) otherwise a float box (decimals + analog repeat). Set the flag BEFORE SetRange, whose auto-step. - // Derives from it, then pin our own step. const bool is_integer = is_whole(min_v) && is_whole(max_v) && is_whole(step_v); *reinterpret_cast(nb_bytes + numbox_is_integer_offset) = is_integer; @@ -1278,10 +1281,9 @@ namespace big::mod_settings g_apply_data(reinterpret_cast(screen), nb); - // ApplyDataToComponent copies the OptionNumBox template's own row grid (Y=300, Spacing=45) into the component - // override it to our grid so the box lines up with the other rows instead of drawing on the previous one - // def_y/def_spacing alias the component's baseY(+0xC8) and pitch(+0x204) that UpdateScrollState reads (def sits at - // component+0xA8). + // ApplyDataToComponent copies the OptionNumBox template's own row grid (Y=300, Spacing=45) into the component, + // so override it with ours or the box draws on top of the previous row. def_y/def_spacing are the baseY and + // pitch that UpdateScrollState reads. { char* def = nb_bytes + component_def_offset; *reinterpret_cast(def + def_y) = row_base_y; @@ -1431,8 +1433,8 @@ namespace big::mod_settings } std::memset(s, 0, slider_sizeof); - // Base GUIComponent constructor (location passed by value. 0 = origin, overridden below by - // ApplyDataToComponent./finalize_row), then install the patched slider vtable (bounded GetArea) over the base + // Base GUIComponent constructor (location passed by value, 0 = origin, overridden below by + // ApplyDataToComponent/finalize_row), then install the patched slider vtable (bounded GetArea) over the base // one, falling back to the unpatched native vtable if the copy was not built. g_gui_component_ctor(s, 0); *reinterpret_cast(s) = g_slider_vtable_patched ? g_slider_vtable_patched : g_slider_vtable; @@ -1469,8 +1471,8 @@ namespace big::mod_settings *reinterpret_cast(s + slider_label_offset) = lbl; *reinterpret_cast(s + slider_value_text_offset) = val; - // Parent container, matching DoShowCategory SetParent is a plain setter (writes mParentContainer), so a direct - // write is equivalent and avoids a vtable call. SetParent writes. GUIComponent::GUIComponent::mParentContainer. + // Parent container, matching DoShowCategory. SetParent is a plain setter (it writes mParentContainer), so a + // direct write is equivalent and avoids a vtable call. *reinterpret_cast(s + slider_parent_offset) = reinterpret_cast(screen) + menu_screen_container_offset; // Name the slider and its value box so ApplyDataToComponent applies the OptionSlider/OptionSliderValueText @@ -1991,7 +1993,7 @@ namespace big::mod_settings // Records or clears a restart-required setting change after the value has been written. If the new value equals the // session baseline (e.g. a toggle flipped and flipped back, or a number re-typed to its original), nothing actually - // changed, so the setting is dropped from the restart list otherwise. + // changed and the setting is dropped from the restart list; otherwise it is recorded. static void note_change_if_restart_required(toml_v2::config_file::config_entry_base* entry, const std::string& new_value_display) { if (!entry || !entry->m_config_file) @@ -3598,7 +3600,7 @@ namespace big::mod_settings } // Reset prompt: shown only inside a single mod's settings (resets that mod) and not while editing. It is hidden - // in the mod list/overview so users cannot reset every mod's config by accident (the. RestoreDefaults hook also + // in the mod list/overview so users cannot reset every mod's config by accident (the RestoreDefaults hook also // swallows the shortcut there). if (screen->m_defaults_button) { @@ -3735,7 +3737,7 @@ namespace big::mod_settings } // True if a remappable control (e.g. Back/Cancel = controller B + keyboard Esc, or Select = controller A + Enter) - // was pressed this frame Bit 0x4 of the control's state is "was pressed" (edge, not held). + // was pressed this frame. Bit 0x4 of the control's state is "was pressed" (edge, not held). static bool control_pressed(void* input, const void* control) { if (!input || !g_input_get_state || !control) @@ -4272,7 +4274,7 @@ namespace big::mod_settings // Persists the game's native Options settings (language, audio volumes, resolution/window/graphics, and all // gameplay/interface/accessibility toggles) to disk. The engine normally does this only when the options screen // finishes closing (MiscSettingsScreen::OnExit -> ProfileManager::SaveProfile), which never runs when we force a - // restart. SaveProfile's synchronous path (async=false, no save spinner) so the files are written before we exit. + // restart. Uses SaveProfile's synchronous path (async=false, no save spinner) so the files are written before we exit. static void flush_native_settings() { if (g_save_profile && g_active_profile) @@ -4472,7 +4474,7 @@ namespace big::mod_settings // Leaving the Mods tab for another category: tear our rows down FIRST, before the native category switch runs. They // would then linger in mComponents on the other category - re-localized by a language change and walked by the native // layout - which can corrupt unrelated widgets (e.g. a category button's label). Doing our own teardown here keeps - // mComponents clean for the native code re-entering the tab rebuilds. + // mComponents clean for the native code; re-entering the tab rebuilds them. if (!is_mods_tab && !g_rows.empty()) { destroy_rows(screen); @@ -4485,7 +4487,7 @@ namespace big::mod_settings if (is_mods_tab) { - // Entering the tab always starts at the mod list drill-down happens in-place via the Update hook, not by + // Entering the tab always starts at the mod list. Drill-down happens in-place via the Update hook, not by // re-entering the category. g_view = View::mod_list; g_view_stem.clear(); @@ -4615,10 +4617,16 @@ namespace big::mod_settings g_slider_set_fraction(slider, static_cast((v - min_v) / range), true); } - // Discrete keyboard/controller stepping for our slider rows, and a disabled-row guard. The native - // The native HandleInput slides mFraction continuously behind a dead-zone, so a small tap can land back on the same - // snapped value. Under keyboard/controller we bypass it and move exactly one step per left/right press edge, gated - // on the slider's own mFocused so only the entered slider reacts. + // Auto-repeat state for the slider row currently taking input. GUIComponentSlider carries no repeat fields of its + // own and only one row can be focused, so a single slot keyed by the component is enough. + static void* g_slider_repeat_component = nullptr; + static float g_slider_repeat_timer = 0.0f; + static int g_slider_repeat_dir = 0; + + // Discrete keyboard/controller stepping for our slider rows, and a disabled-row guard. The native HandleInput + // slides mFraction continuously behind a dead-zone, so a small tap can land back on the same snapped value. Under + // keyboard/controller we bypass it and move whole steps, gated on the slider's own mFocused so only the entered + // slider reacts. A held direction repeats, since one press per step makes a wide range unusable. static bool hook_GUIComponentSlider_HandleInput(void* self, void* input, float dt) { PanelRow* row = self ? find_row(reinterpret_cast(self)) : nullptr; @@ -4630,22 +4638,53 @@ namespace big::mod_settings } if ((row->entry || row->is_virtual_input) && !(g_use_mouse && *g_use_mouse) && *reinterpret_cast(reinterpret_cast(self) + slider_focused_offset)) { - if (g_input_was_right_pressed(input)) + // The repeat needs to know the direction is still HELD. Was*Pressed only reports the press edge, so it + // is the fallback that degrades to one step per press when the level probes are unavailable. + const bool right_down = g_input_is_right_pressed ? g_input_is_right_pressed(input) : g_input_was_right_pressed(input); + const bool left_down = g_input_is_left_pressed ? g_input_is_left_pressed(input) : g_input_was_left_pressed(input); + const int dir = right_down ? 1 : (left_down ? -1 : 0); + + if (self != g_slider_repeat_component) + { + g_slider_repeat_component = self; // focus moved to another slider, so start its repeat fresh + g_slider_repeat_dir = 0; + g_slider_repeat_timer = 0.0f; + } + + bool step_now = false; + if (dir == 0) { - step_slider_row(self, row, 1); + g_slider_repeat_timer = 0.0f; } - else if (g_input_was_left_pressed(input)) + else if (dir != g_slider_repeat_dir) { - step_slider_row(self, row, -1); + step_now = true; // a fresh press steps at once, then waits out the delay + g_slider_repeat_timer = slider_repeat_delay; } - return true; // own the focused slider's input so the native continuous slide never runs + else + { + g_slider_repeat_timer -= dt; + if (g_slider_repeat_timer <= 0.0f) + { + step_now = true; + g_slider_repeat_timer = slider_repeat_interval; + } + } + g_slider_repeat_dir = dir; + + if (step_now) + { + step_slider_row(self, row, dir); + return true; // claim only the frames that actually moved the value, like the native num-box + } + return false; // the native continuous slide still never runs, it cannot land on our step grid } } return big::g_hooking->get_original()(self, input, dt); } - // Button-click hook GUIComponentButton overrides. GUIComponent::OnClicked (vtable slot +0x100, the engine's - // terminal-click), so this is where our button rows' clicks land. + // Button-click hook. GUIComponentButton overrides GUIComponent::OnClicked (the engine's terminal click), so this is + // where our button rows' clicks land. static bool hook_GUIComponentButton_OnClicked(GUIComponent* self, std::uint64_t location) { // Clicking the restart message box's button closes the game (forced restart). Re-validate the button's owner is @@ -4980,7 +5019,7 @@ namespace big::mod_settings void* result = big::g_hooking->get_original()(self, dt, input); - // The original just laid out the key rows for this frame mirror the value columns onto them so the right column + // The original just laid out the key rows for this frame. Mirror the value columns onto them so the right column // tracks scrolling and fade, and show the highlighted row's description in the native description box. if (on_mods_tab) { @@ -5034,7 +5073,7 @@ namespace big::mod_settings { auto* menu = reinterpret_cast(screen); - // Select enters a slider/enum row (so the stick adjusts it) toggles and buttons are left to the native + // Select enters a slider/enum row (so the stick adjusts it). Toggles and buttons are left to the native // component pass. if (g_component_focused && control_pressed(input, g_controls_select)) { @@ -5099,8 +5138,8 @@ namespace big::mod_settings // The screen is really closing now. Tear our rows down first: the engine frees a MenuScreen's components through its // reflection helper (which our rows are deliberately not registered in), not by walking mComponents, so on close it - // would neither free nor double-free them - they would just leak destroy_rows is a no-op when g_rows is already empty - // (e.g. closing off the Mods tab). + // would neither free nor double-free them - they would just leak. destroy_rows is a no-op when g_rows is already + // empty (e.g. closing off the Mods tab). g_options_screen_open = false; // stop gating on_change on this now-closing screen. g_dynamic_refresh_settle = 0.0f; // drop any pending numeric-change refresh for the closing screen. destroy_rows(screen); @@ -5134,9 +5173,9 @@ namespace big::mod_settings void register_hooks() { // Resolve every engine symbol, RVA and offset the Mods tab depends on up front. The symbol map is built from the - // game's live PDB, so if the game updates and a required function moved or was renamed it resolves to null here - // likewise the hardcoded RVAs and struct offsets this feature was reverse-engineered against only match one specific - // Ship build. + // game's live PDB, so if the game updates and a required function moved or was renamed it resolves to null here. + // Likewise, the hardcoded RVAs and struct offsets this feature was reverse-engineered against only match one + // specific Ship build. std::vector missing; const auto require = [&](const char* name) -> gmAddress { @@ -5207,6 +5246,11 @@ namespace big::mod_settings g_input_was_left_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasLeftPressed"].as_func(); g_input_was_right_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasRightPressed"].as_func(); + // The level counterparts of the above, so holding a direction repeats instead of stepping once. Optional - + // without them a slider still steps, just once per press. + g_input_is_left_pressed = big::hades2_symbol_to_address["sgg::InputHandler::IsLeftPressed"].as_func(); + g_input_is_right_pressed = big::hades2_symbol_to_address["sgg::InputHandler::IsRightPressed"].as_func(); + // Native-settings flush before a forced restart. SaveProfile persists language, volumes, graphics and gameplay // toggles. Optional - missing symbols only mean those native edits may wait for a normal save. g_save_profile = big::hades2_symbol_to_address["sgg::ProfileManager::SaveProfile"].as_func(); @@ -5232,7 +5276,7 @@ namespace big::mod_settings // The hardcoded RVAs and struct offsets above are valid only for the build they were captured against. // We gate the (attempted) creation of the menu itself on a valid GUID to not crash the game unnecessarily. - // The config API itself works regardless + // The config API itself works regardless. static constexpr const char* validated_pdb_guids[] = { "744ea71c-2c21-4b40-a6c486d1fa6647da", // Ship, 2026-08-04 }; @@ -5247,7 +5291,7 @@ namespace big::mod_settings ::module_info_helper::get_module_base_and_size(&game_base, &game_size, nullptr); const bool build_matches = anchor && game_base && (anchor.as() - game_base == anchor_rva); - // push_back is a named PDB symbol but is occasionally emitted inline fall back to its RVA + // push_back is a named PDB symbol but is occasionally emitted inline, so fall back to its RVA. if (!g_push_back && build_matches) { g_push_back = reinterpret_cast(anchor.as() - anchor_rva + push_back_rva); @@ -5335,7 +5379,7 @@ namespace big::mod_settings { static auto set_fraction_hook = hooking::detour_hook_helper::add_queue("sgg::GUIComponentSlider::SetFraction", slider_set_fraction); - // Discrete keyboard/controller stepping needs the left/right edge probes. Without them our slider rows keep + // Discrete keyboard/controller stepping needs the left/right probes. Without them our slider rows keep // the native continuous slide, so only install the input override when both resolved. const auto slider_handle_input = big::hades2_symbol_to_address["sgg::GUIComponentSlider::HandleInput"]; if (slider_handle_input && g_input_was_left_pressed && g_input_was_right_pressed) diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp index c35145f..53a418f 100644 --- a/src/hades2/mod_settings/sgg_gui.hpp +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -18,7 +18,7 @@ namespace big::mod_settings::sgg static_assert(sizeof(Vec2) == 8); - // eastl::vector stores three pointers (begin, end, capacity) followed by its allocator begin/end are enough to + // eastl::vector stores three pointers (begin, end, capacity) followed by its allocator. begin/end are enough to // iterate an existing vector. template struct eastl_vector From c7cc52ead59e5321bd023d5863cfec5e73747cab Mon Sep 17 00:00:00 2001 From: NikkelM <57323886+NikkelM@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:34:33 +0100 Subject: [PATCH 083/100] Updated comments --- docs/mod_settings/README.md | 2 +- src/hades2/mod_settings/config_api.cpp | 239 ++--- src/hades2/mod_settings/mod_settings.cpp | 1104 +++++++--------------- src/hades2/mod_settings/mod_settings.hpp | 123 +-- src/hades2/mod_settings/sgg_gui.hpp | 30 +- 5 files changed, 461 insertions(+), 1037 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index a3e9615..aa35f53 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -39,7 +39,7 @@ below. Two other kinds of `configDesc` entry have their own fields and sections: | `hidden` | boolean | Hide the setting from the menu entirely. Static only - use `disabled` for rows that change state while the menu is open. | | `disabled` | boolean \| callback | Grey the setting out (read-only) while true. Updates live while the menu is open. | | `disabledDescription` | string \| localization table \| callback | Description shown in place of `description` while the setting is greyed by its own `disabled` field, to explain why. Falls back to `description` when omitted. | -| `restartRequired` | boolean | Force the user to restart the game after existing the mod menu if this setting was changed. | +| `restartRequired` | boolean | Force the user to restart the game after exiting the mod menu if this setting was changed. | | `editableContext` | `"any"` \| `"mainMenu"` \| `"inSave"` \| `"inHub"` | Restrict where the row can be edited: `"any"` (default), `"mainMenu"` (only from the main menu), `"inSave"` (only while a save is loaded), or `"inHub"` (only in the Crossroads). Outside of the allowed context the row shows as disabled. Restrict this if the mod or game would break if the setting is edited in the wrong context. Can also be set on a whole menu category, which restricts everything inside it. The "enabled" setting and any `restartRequired` settings are always treated as `"mainMenu"`. | | `showAsPercentage` | boolean | Append "%" to the value. Usually used for min/max restricted number fields. | | `isPercentage` | boolean | Show a 0..x value as 0..x00 *and* append "%". You don't need `showAsPercentage` when using this. | diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 5918a00..ff0a0df 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -30,28 +30,20 @@ namespace big::mod_settings { #pragma region Metadata registries and accessors - // Author-declared per-setting metadata, populated from each mod's config.lua by rom.mod_settings.load. - // Only settings with a rich-table description are registered, the rest fall back to type-based rendering. static std::mutex g_metadata_mutex; static std::map g_setting_metadata; - // Serialized config.lua default for every bound key, captured at load. The menu's Reset action restores this value. static std::map g_setting_default; - // (section, key) pairs that carry a configDesc entry. A key with none is hidden from the menu. static std::set g_described_keys; - // Guids of mods that called rom.mod_settings.opt_out(), mapped to the optional custom description they passed. static std::map g_opted_out_mods; - // Action buttons declared in config.lua (configDesc entries with an `action` function). static std::map> g_actions; - // Virtual rows declared in config.lua. static std::map> g_virtual_rows; - // Author-declared menu group trees (top-level configDesc `groups`). These are the menu categories a per-entry - // `group` can reference that do not correspond to a config section. + // Author-declared menu categories that do not correspond to config sections. static std::map> g_menu_groups; static constexpr const char* root_section = "config"; @@ -144,11 +136,10 @@ namespace big::mod_settings #pragma region Config.lua parsing helpers - static std::string serialize_option(const sol::object& v); // defined below. - static editable_context parse_editable_context(const sol::object& o, editable_context fallback); // defined below. + static std::string serialize_option(const sol::object& v); + static editable_context parse_editable_context(const sol::object& o, editable_context fallback); - // Parses a user-facing string field: either a plain scalar (stored under the empty key) or a localization table - // keyed by language folder codes, e.g. { en = "...", ["zh-TW"] = "..." }. Empty/absent yields an empty map. + // Accepts a plain scalar or a language-code table. Empty or absent yields an empty map. static localized_text parse_localized(const sol::object& o) { localized_text out; @@ -172,8 +163,7 @@ namespace big::mod_settings return out; } - // Resolves a localized string to a single language-independent value for on-disk use (the .cfg comment), which is - // not re-written per language: English, then the unlocalized value, then any entry. + // Picks one language-independent value for the on-disk .cfg comment. static std::string localized_fallback(const localized_text& t) { if (t.empty()) @@ -191,9 +181,7 @@ namespace big::mod_settings return t.begin()->second; } - // Extracts the (possibly localized) description from a config.lua description value, which may be a plain string, - // or a rich table with a `description` field (or `[1]` shorthand for backwards-compatibility) that is itself a - // plain string or a localization table. + // Reads a plain description or a rich table's `description` or `[1]` field. static localized_text describe(const sol::object& desc) { if (desc.get_type() == sol::type::string) @@ -217,9 +205,7 @@ namespace big::mod_settings return {}; } - // Parses a configDesc `group` field into a menu path (the ordered group segments the entry is moved under). Accepts - // a plain string (a single-level group) or an array of strings (a nested path). Non-string entries are ignored. - // Empty result means no override (the entry keeps its config-section placement). + // Accepts a string or string array as a menu path. Empty means no override. static std::vector parse_group(const sol::object& o) { std::vector out; @@ -239,8 +225,7 @@ namespace big::mod_settings } } } - // '.' is the menu-path separator, so a segment carrying one would silently mis-nest. Reject the whole override - // (the row keeps its config-section placement) and tell the author to use an array of segments to nest. + // '.' is the menu-path separator, so reject ambiguous segments. for (const auto& seg : out) { if (seg.find('.') != std::string::npos) @@ -252,9 +237,7 @@ namespace big::mod_settings return out; } - // Recursively parses a configDesc `groups` table into menu_group nodes. Each key is a group id (used in a `group` - // path), its value a table of optional `displayName`/`description`/`disabled`/`disabledDescription`, `order`, and - // nested `groups`. Non-tables are skipped. Siblings sort by `order` then id (Lua declaration order is lost). + // Parses configDesc `groups` into menu_group nodes. Siblings sort by `order` then id because Lua order is lost. static std::vector parse_menu_groups(const sol::object& groups_obj) { std::vector out; @@ -291,8 +274,7 @@ namespace big::mod_settings } g.context = parse_editable_context(gt["editableContext"], editable_context::any); - // A field written as a Lua function is skipped by the type-guarded reads above and re-evaluated at - // render by resolve_menu_group. + // Lua-function fields are re-evaluated by resolve_menu_group. for (const char* field : {"displayName", "description", "disabledDescription", "disabled"}) { if (gt[field].get_type() == sol::type::function) @@ -307,7 +289,6 @@ namespace big::mod_settings return out; } - // True if a config.lua description table declares `restartRequired = true`. static bool description_requires_restart(const sol::object& desc) { if (!desc.is()) @@ -318,8 +299,7 @@ namespace big::mod_settings return flag.is() && flag.as(); } - // Parses an `editableContext` field ("any"/"mainMenu"/"inSave"/"inHub"), returning `fallback` for anything else. - // Shared by setting metadata and action buttons. + // Parses an `editableContext` field, returning `fallback` for unknown values. static editable_context parse_editable_context(const sol::object& o, editable_context fallback) { if (o.get_type() == sol::type::string) @@ -345,9 +325,7 @@ namespace big::mod_settings return fallback; } - // Serializes a Lua enum-option value (bool/number/string) into the exact string form a config entry serializes to, - // so the menu can match an option against the stored value. Numbers use the same locale-invariant std::format the - // toml converter uses, and every config number is stored as a double. + // Serializes a Lua enum option exactly as a config entry serializes it. static std::string serialize_option(const sol::object& v) { switch (v.get_type()) @@ -359,7 +337,6 @@ namespace big::mod_settings } } - // Reads the array part of a Lua list table (ipairs order), applying `transform` to each element. template static void read_list(const sol::object& obj, std::vector& out, Transform transform) { @@ -378,8 +355,7 @@ namespace big::mod_settings #pragma region Metadata extraction - // Builds a setting_metadata from a config.lua description table for a flat (non-table) value. The widget - // kind is not stored - the menu derives it from the value's type plus the presence of `values` (enum). + // The menu derives widget kind from the value type plus `values`. static setting_metadata extract_metadata(const sol::table& desc) { setting_metadata m; @@ -458,8 +434,7 @@ namespace big::mod_settings m.restart_required = description_requires_restart(desc); - // Virtual-row `type`: an author-forced widget kind for when get() may be nil at build time (config settings - // ignore this, their value always exists). Accepts "boolean"/"bool", "number", "string", "enum"/"enumeration". + // Virtual-row `type` pins the widget when get() may be nil at build time. if (sol::object type_field = desc["type"]; type_field.get_type() == sol::type::string) { const std::string t = type_field.as(); @@ -481,19 +456,17 @@ namespace big::mod_settings } } - // Virtual-row `default`: the value a menu Reset restores the row to (config settings recover their own default). + // Virtual-row `default` is restored by Reset. if (sol::object default_field = desc["default"]; default_field.valid() && default_field.get_type() != sol::type::lua_nil) { m.has_default = true; m.default_value = serialize_option(default_field); } - // When the setting may be changed relative to a loaded save (`editableContext`). The menu forces the - // "enabled" toggle and any restartRequired settings to main_menu regardless. + // The menu forces the master toggle and restartRequired settings to main_menu. m.context = parse_editable_context(desc["editableContext"], editable_context::any); - // A field written as a Lua function is dynamic: skipped by the type-guarded reads above and re-evaluated at - // render. + // Lua-function fields are re-evaluated at render. for (const char* field : {"displayName", "description", "disabledDescription", "min", "max", "step", "values", "labels", "order", "disabled"}) { if (desc[field].get_type() == sol::type::function) @@ -503,7 +476,6 @@ namespace big::mod_settings } } - // Menu placement override: the author-declared `group` this entry appears under instead of its config section. m.group = parse_group(desc["group"]); return m; @@ -513,9 +485,7 @@ namespace big::mod_settings #pragma region Description navigation and dynamic-field resolution - // The Lua-side registry (rom.mod_settings._descs) mapping guid -> the mod's raw configDesc table, kept alive so - // dynamic description fields and action callbacks can be evaluated at render. Recreated each Lua state, so it never - // dangles. + // Lua-owned configDesc registry. Recreated each Lua state, so C++ sol references never dangle. static sol::object stored_descriptions(sol::state_view state, const std::string& guid) { sol::object ns = state[rom::g_lua_api_namespace]; @@ -536,8 +506,7 @@ namespace big::mod_settings return descs.as()[guid]; } - // Navigates a mod's stored configDesc to the description of (section, key). The configDesc mirrors the config table - // under the "config" root, so the section's remaining path indexes nested description tables. + // configDesc mirrors the config table under the "config" root. static sol::object navigate_description(const sol::object& root, const std::string& section, const std::string& key) { if (!root.is()) @@ -572,17 +541,13 @@ namespace big::mod_settings return node[key]; } - // A minimal Lua message handler that returns the error object unchanged. Unlike ReturnOfModding's global default - // handler it neither appends a stack traceback nor logs the failure at ERROR (and does not count it against the - // mod's error tally). + // Avoids ReturnOfModding's traceback logging and error tally. static int silent_error_handler(lua_State* /*L*/) { - return 1; // keep the single error value already on the stack. + return 1; // keep the error object on the stack. } - // Invokes a mod-supplied Lua callback protected, with the silent handler above rather than ReturnOfModding's - // default: callers report failures themselves with one concise WARNING, so a callback that legitimately fails in - // some contexts (e.g. reading run state from the main menu) does not also spam an ERROR plus full traceback. + // Uses the silent handler so callers can report one concise warning. template static sol::protected_function_result call_mod_callback(sol::protected_function fn, Args&&... args) { @@ -591,8 +556,6 @@ namespace big::mod_settings return fn(std::forward(args)...); } - // Calls a dynamic description field (a Lua function) protected, returning its result, or nil on error (logged). - // Non-function values are returned unchanged. static sol::object evaluate_field(const sol::object& value, const std::string& guid, const char* field) { if (value.get_type() != sol::type::function) @@ -610,9 +573,7 @@ namespace big::mod_settings return rv.get(); } - // Shallow-copies a description table with every dynamic (function) field replaced by its evaluated value, so - // extract_metadata can read it as static. Event callables are left as-is: `onChanged`, `action`, and a virtual row's - // `get`/`set`/`text`. + // Evaluates dynamic fields while preserving event and virtual-row callbacks. static sol::table resolve_description(sol::state_view state, const sol::table& desc, const std::string& guid) { sol::table out = state.create_table(); @@ -638,7 +599,6 @@ namespace big::mod_settings #pragma region Action and virtual-row collection - // Reads the static (non-function) action metadata common to collection and dynamic re-resolution. static void read_action_fields(const sol::table& entry, action_info& a) { a.name = parse_localized(entry["displayName"]); @@ -657,9 +617,7 @@ namespace big::mod_settings a.group = parse_group(entry["group"]); } - // Walks a mod's configDesc (guided by the config defaults, like bind_defaults) collecting action buttons - - // description entries carrying an `action` function and no config value. Recurses into groups so actions can live at - // any level. + // Collects action buttons from configDesc, guided by config defaults. static void collect_actions(const sol::table& config_tbl, const sol::object& desc_obj, const std::string& section, std::vector& out) { if (desc_obj.is()) @@ -691,8 +649,6 @@ namespace big::mod_settings out.push_back(std::move(a)); } } - - // Recurse into child sections following the config structure (a table value is a group). for (const auto& [k, v] : config_tbl) { if (k.get_type() != sol::type::string || !v.is()) @@ -704,8 +660,7 @@ namespace big::mod_settings } } - // configDesc field names that are metadata OF an entry, not child keys. Skipped when walking a desc table for child - // rows so a group's own displayName/description/... are not mistaken for missing config keys. + // Entry metadata fields are skipped when walking desc child rows. static bool is_reserved_desc_field(const std::string& key) { static const std::set reserved = { @@ -732,15 +687,13 @@ namespace big::mod_settings "text", "type", "default", - "group", // per-entry menu placement override - "groups", // top-level author group-tree declaration (root configDesc only) + "group", // per-entry menu placement. + "groups", // root author group tree. }; return reserved.contains(key); } - // Walks a mod's configDesc collecting virtual rows and validating every entry: it must resolve to a config value, an - // `action`, or an explicit `virtual = true`. Anything else is logged as a likely author mistake (usually a described - // key missing from `config`), as is a `virtual` row with no `get`/`text`. + // Collects virtual rows and logs desc entries that resolve to no config value, action, or virtual row. static void collect_virtual_rows(const std::string& guid, const sol::table& config_tbl, const sol::object& desc_obj, const std::string& section, std::vector& out) { if (desc_obj.is()) @@ -753,9 +706,6 @@ namespace big::mod_settings continue; } const std::string key = k.as(); - - // Skip the current node's own metadata fields (a group/root desc mixes them with child descriptions), - // so they are never mistaken for a child config key. if (is_reserved_desc_field(key)) { continue; @@ -764,9 +714,6 @@ namespace big::mod_settings const std::string path = section + "." + key; const sol::object cfg_val = config_tbl[key]; const bool has_config = cfg_val.valid() && cfg_val.get_type() != sol::type::lua_nil; - - // A plain-string description for a key with no config value is an orphan (a described key never added - // to config). A string desc for a real config key is fine (bind_defaults handles it). if (v.get_type() == sol::type::string) { if (!has_config) @@ -786,7 +733,6 @@ namespace big::mod_settings if (has_config) { - // Config-backed setting or a config group (recursed below). `virtual` here is contradictory. if (is_virtual) { LOG(WARNING) << "[mod_settings] " << guid << ": configDesc entry '" << path << "' is marked virtual = true but also has a config value; treating it as a normal config setting."; @@ -795,11 +741,10 @@ namespace big::mod_settings } if (is_action) { - continue; // collected by collect_actions. + continue; } if (!is_virtual) { - // No config value, no action, no virtual marker: the author most likely forgot the config entry. LOG(WARNING) << "[mod_settings] " << guid << ": configDesc entry '" << path << "' has no matching config value and is not marked `virtual = true` or given an `action`. Did you forget to add '" << key << "' to config?"; continue; } @@ -818,10 +763,7 @@ namespace big::mod_settings const bool has_set = entry["set"].get_type() == sol::type::function; const sol::object t = entry["text"]; const bool has_text = t.get_type() == sol::type::string || t.get_type() == sol::type::function; - - // A row is interactive (an editable get/set widget) when it has a `set`, otherwise it is a read-only - // `text` row. - vr.interactive = has_set; + vr.interactive = has_set; for (const char* field : {"displayName", "description", "text", "values", "min", "max", "step", "labels"}) { if (entry[field].get_type() == sol::type::function) @@ -830,7 +772,7 @@ namespace big::mod_settings break; } } - if (has_get || entry["values"].valid()) // an interactive row's value is dynamic by nature. + if (has_get || entry["values"].valid()) // interactive rows with get/values are dynamic. { vr.has_dynamic = vr.has_dynamic || vr.interactive; } @@ -850,8 +792,6 @@ namespace big::mod_settings out.push_back(std::move(vr)); } } - - // Recurse into child config sections (a table config value is a group), like collect_actions. for (const auto& [k, v] : config_tbl) { if (k.get_type() != sol::type::string || !v.is()) @@ -867,15 +807,13 @@ namespace big::mod_settings #pragma region Config entry access and change hooks - // Finds the config entry for (section, key), or nullptr. static toml_v2::config_file::config_entry_base* find_entry(toml_v2::config_file* cf, const std::string& section, const std::string& key) { toml_v2::config_definition def(section, key); return cf->try_get_entry(def); } - // True if `section` is a bound section or the parent of one (some entry's section equals `section` or starts with - // `section + "."`). Used to expose nested config tables via the proxy. + // Treats a section as present if it has bound leaves or child sections. static bool has_section(toml_v2::config_file* cf, const std::string& section) { const std::string prefix = section + "."; @@ -889,7 +827,6 @@ namespace big::mod_settings return false; } - // Reads a config entry's value as the matching Lua type. static sol::object entry_get(sol::this_state ts, toml_v2::config_file::config_entry_base* entry) { const auto& t = entry->type(); @@ -908,7 +845,6 @@ namespace big::mod_settings return sol::lua_nil; } - // Writes a Lua value into a config entry, dispatching on the value's Lua type. static void entry_set(toml_v2::config_file::config_entry_base* entry, const sol::object& value) { switch (value.get_type()) @@ -920,10 +856,7 @@ namespace big::mod_settings } } - // Routes toml_v2's m_setting_changed (fired after a value changes and the file is saved) to a Lua onChanged - // callback. Fires for edits made through the options menu, but not a mod's own config write outside it. A same-value - // write is a no-op, so a callback that writes back cannot loop. Stored on the entry, which the mod's config_file - // owns and which dies with the Lua state, so the captured sol reference never dangles. + // onChanged fires only for options-menu edits. The entry owns the callback for the Lua state's lifetime. static void attach_on_change(toml_v2::config_file::config_entry_base* entry, sol::protected_function callback) { if (!entry || !callback.valid()) @@ -947,8 +880,7 @@ namespace big::mod_settings }; } - // Parses a config key that is a positive-integer array index ("1", "2", ...), used to expose array-like sections - // through #, ipairs and inext. + // Positive integer keys make array-like sections work with #, ipairs, and inext. static bool parse_positive_index(const std::string& key, long& out) { if (key.empty()) @@ -972,19 +904,14 @@ namespace big::mod_settings #pragma region Config proxy - // Registry keys for the config proxy: one shared metatable, plus two weak-keyed maps from each wrapper table to the - // config_file and section it points at, so the metamethods can recover them per call. + // Shared metatable plus weak-keyed wrapper maps for config_file and section. static constexpr const char* k_proxy_metatable = "h2m_mod_config_metatable"; static constexpr const char* k_proxy_cf_map = "h2m_mod_config_cf"; static constexpr const char* k_proxy_section_map = "h2m_mod_config_section"; - // Builds the (empty) Lua table wrapper mods receive as their `config`, so `type(config) == "table"` (matching - // SGG_Modding-Chalk). Defined after mod_config_proxy, but the struct's child accessors call it, so forward-declare. static sol::object make_proxy(sol::this_state ts, toml_v2::config_file* cf, const std::string& section); - // Live read/write view over a config_file section, returned to the mod as its `config`. Reads/writes go straight - // through to the underlying entries (so the menu and the mod see the same values), nested sections resolve to child - // proxies. Holds a raw config_file pointer owned by the mod and recreated with it per Lua state, so nothing dangles. + // Live config view. The mod owns the config_file and recreates it with each Lua state. struct mod_config_proxy { toml_v2::config_file* cf = nullptr; @@ -1012,9 +939,7 @@ namespace big::mod_settings return; } - // Assigning a whole table to a nested section (e.g. config.group = { a = 1, b = 2 }) sets each matching leaf - // in that child section, recursing for deeper tables. Only existing bound leaves are written - string keys - // with no entry are ignored. + // Assigning a table to a nested section writes only existing bound leaves. const std::string child = section + "." + key; if (value.is() && has_section(cf, child)) { @@ -1029,8 +954,6 @@ namespace big::mod_settings } } - // Snapshots this section's immediate children into a fresh Lua table (each leaf key to its value, each - // sub-section to a child proxy) so the iteration metamethods can hand it to Lua's pairs/next. sol::table children_snapshot(sol::this_state ts) const { sol::state_view lua(ts); @@ -1056,7 +979,7 @@ namespace big::mod_settings return out; } - // __len: highest positive-integer leaf key at this level (array length), 0 for a purely string-keyed section. + // __len returns the highest positive-integer leaf key. std::size_t length() const { std::size_t n = 0; @@ -1071,7 +994,7 @@ namespace big::mod_settings return n; } - // __pairs: `for k, v in pairs(config)` walks one level (leaf values plus child proxies), like a plain table. + // __pairs walks one level like a plain table. std::tuple pairs(sol::this_state ts) const { sol::state_view lua(ts); @@ -1081,7 +1004,7 @@ namespace big::mod_settings return std::make_tuple(r.get(0), r.get(1), r.get(2)); } - // __ipairs (consulted by ipairs on Lua 5.2): iterate the 1..n integer-keyed leaves of an array-like section. + // __ipairs is consulted by ipairs on Lua 5.2. std::tuple ipairs(sol::this_state ts) const { sol::state_view lua(ts); @@ -1099,7 +1022,7 @@ namespace big::mod_settings return std::make_tuple(r.get(0), r.get(1), r.get(2)); } - // __next (consulted by ModUtil's next/qrawpairs): step to the pair after `key` at this level. + // __next is used by ModUtil's next/qrawpairs. std::tuple next(sol::this_state ts, sol::object key) const { sol::state_view lua(ts); @@ -1109,7 +1032,7 @@ namespace big::mod_settings return std::make_tuple(r.get(0), r.get(1)); } - // __inext (consulted by ModUtil's inext/qrawipairs): step to index i + 1 of an array-like section. + // __inext is used by ModUtil's inext/qrawipairs. std::tuple inext(sol::this_state ts, sol::object index) const { long i = 0; @@ -1130,8 +1053,7 @@ namespace big::mod_settings { sol::state_view lua(ts); sol::table registry = lua.registry(); - // The wrapper is an empty table: a shared metatable drives every read/write, and its (cf, section) live in the - // weak-keyed registry maps, so nothing leaks into rawpairs and the wrapper is collected with its section. + // The empty wrapper keeps state in weak-keyed maps, so rawpairs stays empty. sol::table wrapper = lua.create_table(); sol::table metatable = registry[k_proxy_metatable]; sol::table cf_map = registry[k_proxy_cf_map]; @@ -1142,7 +1064,6 @@ namespace big::mod_settings return wrapper; } - // Recovers the (cf, section) a wrapper table points at, as a throwaway proxy the free metamethods delegate to. static mod_config_proxy recover(sol::this_state ts, const sol::table& wrapper) { sol::state_view lua(ts); @@ -1154,7 +1075,7 @@ namespace big::mod_settings return mod_config_proxy{cf, section}; } - // Coerces a Lua index key to the string form config entries use (Chalk stringifies numeric keys). + // Chalk stringifies numeric config keys. static bool coerce_key(const sol::stack_object& key, std::string& out) { if (key.get_type() == sol::type::string) @@ -1219,7 +1140,6 @@ namespace big::mod_settings #pragma region Default binding and config.lua load - // A setting's extracted metadata together with the section/key it belongs to, collected while walking config.lua. struct collected_metadata { std::string section; @@ -1227,9 +1147,7 @@ namespace big::mod_settings setting_metadata meta; }; - // Recursively binds a config.lua `defaults` table into `cf` under `section`, nested tables becoming sub-sections. - // bind adopts a value already in the .cfg (preserving user edits) under section "config", keeping the file - // byte-compatible with SGG_Modding-Chalk. + // Existing .cfg values are adopted under section "config" to stay byte-compatible with SGG_Modding-Chalk. static void bind_defaults(toml_v2::config_file* cf, const sol::table& defaults, const sol::object& desc_obj, const std::string& section, std::vector& meta_out, std::vector>& defaults_out, std::vector>& described_out) { sol::table desc_tbl; @@ -1277,21 +1195,18 @@ namespace big::mod_settings default: continue; } - // Capture the config.lua default, serialized exactly as the entry serializes its own value, so the menu's - // Reset can round-trip it back through set_serialized_value. + // Capture the serialized default for Reset. if (default_any) { defaults_out.emplace_back(section, key, toml_v2::toml_type_converter::convert_to_string(*default_any)); - // An undescribed leaf is hidden from the menu. if (described) { described_out.emplace_back(section, key); } } - // A rich description table carries metadata: the setting's own for a leaf, or group-level metadata (order, - // displayName, ...) for a nested group. Registered under (section, key) either way. + // Rich description tables carry leaf or group metadata. if (desc.is()) { meta_out.push_back({section, key, extract_metadata(desc.as())}); @@ -1330,8 +1245,7 @@ namespace big::mod_settings } const std::string guid = module->guid(); - // .cfg path = rom.path.combine(rom.paths.config(), guid .. ".cfg") - identical to the path Chalk used, so an - // existing .cfg is reused. + // Reuses Chalk's .cfg path. sol::table rom = env["rom"]; sol::function path_combine = rom["path"]["combine"]; sol::function config_folder = rom["paths"]["config"]; @@ -1362,7 +1276,7 @@ namespace big::mod_settings sol::object defaults = cfg_result[0]; sol::object descriptions = cfg_result[1]; - // Section root is "config", matching Chalk, so an existing .cfg stays byte-compatible. + // Root section matches Chalk for .cfg compatibility. std::vector collected; std::vector> collected_defaults; // (section, key, serialized) std::vector> collected_described; // (section, key) with a desc @@ -1372,9 +1286,7 @@ namespace big::mod_settings } cf->save(); - // Keep this mod's configDesc alive in Lua so the menu can evaluate dynamic (function) description fields and - // action callbacks at render time. Lua-owned and recreated per state, so no sol reference dangles in a C++ - // static. + // Keep configDesc Lua-owned so dynamic fields and action callbacks do not dangle across state resets. if (sol::object ms_ns = rom["mod_settings"]; ms_ns.is()) { if (sol::object descs = ms_ns.as()["_descs"]; descs.is()) @@ -1383,28 +1295,24 @@ namespace big::mod_settings } } - // Collect action buttons declared in configDesc (entries with an `action` function and no config value). std::vector actions; if (defaults.is()) { collect_actions(defaults.as(), descriptions, root_section, actions); } - // Also validates that every configDesc entry resolves to a config value, an action, or a virtual marker. std::vector virtual_rows; if (defaults.is()) { collect_virtual_rows(guid, defaults.as(), descriptions, root_section, virtual_rows); } - // The categories a per-entry `group` can target that do not exist as config sections. std::vector menu_groups; if (descriptions.is()) { menu_groups = parse_menu_groups(descriptions.as()["groups"]); } - // Register this mod's setting metadata (replacing any from a previous load of the same mod). { std::scoped_lock lock(g_metadata_mutex); clear_metadata_for(guid); @@ -1447,7 +1355,7 @@ namespace big::mod_settings } const sol::table resolved = resolve_description(state, desc.as(), guid); setting_metadata m = extract_metadata(resolved); - m.has_dynamic = false; // already resolved to concrete values. + m.has_dynamic = false; // resolved to concrete values. return m; } @@ -1464,7 +1372,6 @@ namespace big::mod_settings return std::nullopt; } - // Walk the declaration tree: root `groups` for the first id, then each node's own `groups` for the next. sol::object node = root.as()["groups"]; sol::table entry; for (std::size_t i = 0; i < path.size(); ++i) @@ -1501,8 +1408,7 @@ namespace big::mod_settings return g; } - // True when the game is in the hub (the Crossroads), i.e. the game Lua global `CurrentHubRoom` is non-nil. Reads - // the game's Lua state directly, so it must be called on the game thread while the state is alive. + // Reads CurrentHubRoom from the game Lua state. Call on the game thread while the state is alive. bool game_is_in_hub() { if (!big::g_lua_manager) @@ -1526,14 +1432,14 @@ namespace big::mod_settings } for (const auto& a : it->second) { - if (section.empty() || a.section == section) // empty section = all sections (menu-path bucketing) + if (section.empty() || a.section == section) // empty section means all sections. { result.push_back(a); } } } - // Re-evaluate any dynamic fields against the current game state, mirroring resolve_setting_metadata. + // Re-evaluate dynamic fields against the current game state. if (big::g_lua_manager) { sol::state_view state = big::g_lua_manager->lua_state(); @@ -1592,7 +1498,7 @@ namespace big::mod_settings } for (const auto& vr : it->second) { - if (section.empty() || vr.section == section) // empty section = all sections (menu-path bucketing) + if (section.empty() || vr.section == section) // empty section means all sections. { result.push_back(vr); } @@ -1615,7 +1521,7 @@ namespace big::mod_settings } sol::table t = desc.as(); - // `text` is a plain string, or a function returning a bool/number/string that is stringified. + // `text` may be a string or a function returning a stringifiable scalar. const sol::object text = t["text"]; if (text.get_type() == sol::type::string) { @@ -1678,7 +1584,7 @@ namespace big::mod_settings out.type = virtual_value::kind::string; out.as_string = v.as(); break; - default: break; // kind::none - the widget falls back to a read-only display. + default: break; // kind::none falls back to read-only display. } return out; } @@ -1708,7 +1614,7 @@ namespace big::mod_settings case virtual_value::kind::boolean: rv = call_mod_callback(fn, value.as_bool); break; case virtual_value::kind::number: rv = call_mod_callback(fn, value.as_number); break; case virtual_value::kind::string: rv = call_mod_callback(fn, value.as_string); break; - default: return; // nothing to write. + default: return; } if (!rv.valid()) { @@ -1721,7 +1627,6 @@ namespace big::mod_settings #pragma region Virtual-row value helpers and reset - // Parses a serialized scalar (as produced by serialize_option) back to a double, or 0.0 if it is not numeric. static double parse_serialized_number(const std::string& s) { try @@ -1734,8 +1639,7 @@ namespace big::mod_settings } } - // The virtual_value kind an author-forced widget_type maps to (enum options and strings are both carried as - // strings). + // Enum options and strings are both carried as strings. static virtual_value::kind kind_of_widget(widget_type t) { switch (t) @@ -1748,7 +1652,6 @@ namespace big::mod_settings } } - // Builds a typed virtual_value from a serialized scalar for the given kind. static virtual_value virtual_value_from_serialized(virtual_value::kind kind, const std::string& serialized) { virtual_value v; @@ -1763,8 +1666,7 @@ namespace big::mod_settings return v; } - // Best-effort kind for a serialized default when neither get() nor an explicit `type` pins it: "true"/"false" is a - // boolean, an all-numeric parse is a number, everything else is a string. + // Guesses a default's kind when neither get() nor `type` pins it. static virtual_value::kind guess_kind_from_serialized(const std::string& s) { if (s == "true" || s == "false") @@ -1798,7 +1700,7 @@ namespace big::mod_settings { if (vr.section == section && vr.key == key) { - interactive = vr.interactive; // read-only rows have no set() to restore through. + interactive = vr.interactive; // read-only rows have no set() to reset. break; } } @@ -1812,11 +1714,10 @@ namespace big::mod_settings const auto meta = resolve_setting_metadata(guid, section, key); if (!meta || !meta->has_default) { - return false; // only rows that declare a `default` are reset. + return false; // only rows with `default` reset. } - // Prefer the live get() kind, then an explicit `type`, then `values` (enum -> string), then a guess from the - // default's serialized form. + // Prefer live get() kind, then `type`, enum values, then the serialized default. const virtual_value cur = get_virtual_value(guid, section, key); virtual_value::kind kind = cur.type; if (kind == virtual_value::kind::none) @@ -1851,8 +1752,6 @@ namespace big::mod_settings // cannot be opened. Use it when the mod should not be edited in-game. Works with Chalk or rom.mod_settings.load. static void opt_out(sol::this_environment this_env, sol::object description) { - // Keyed by the calling mod's guid (which matches its config-file stem), so the menu can grey the matching row - // however the mod manages its config. if (!this_env) { return; @@ -1873,9 +1772,7 @@ namespace big::mod_settings void bind_config_api(sol::state_view& state, sol::table& lua_ext) { - // A fresh Lua state re-runs every mod's main.lua, so drop all per-mod registries before those calls re-register - // them. A mod uninstalled since the last state never calls load again, so clearing everything here keeps the - // registries bounded to the currently-loaded mods. + // Each fresh Lua state re-registers loaded mods, so clear per-mod registries first. { std::scoped_lock lock(g_metadata_mutex); g_setting_metadata.clear(); @@ -1887,9 +1784,7 @@ namespace big::mod_settings g_described_keys.clear(); } - // The config object handed to mods is a plain Lua table (so `type(config) == "table"`, matching Chalk) driven by - // one shared metatable reproducing Chalk's metamethod surface, plus ModUtil's next/inext. Each wrapper's - // (cf, section) live in weak-keyed registry maps, so the wrapper stays empty and is collected with it. + // The proxy stays a plain empty Lua table while weak-keyed maps hold its live config state. sol::table proxy_metatable = state.create_table(); proxy_metatable["__index"] = &proxy_index; proxy_metatable["__newindex"] = &proxy_new_index; @@ -1915,9 +1810,7 @@ namespace big::mod_settings ns.set_function("load", &load); ns.set_function("opt_out", &opt_out); - // A Lua-owned table holding each mod's raw configDesc, so the menu can evaluate dynamic description fields and - // action callbacks at render time without caching sol references in C++ statics (which would dangle across a - // Lua-state reset). + // Lua-owned configDesc storage avoids dangling C++ sol references across Lua-state resets. ns["_descs"] = state.create_table(); } diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 8fc34a9..1bcc80a 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -35,16 +35,13 @@ namespace big::mod_settings using sgg::MiscSettingsScreen; using sgg::Vec2; - // GUIComponent::mName, an eastl::string ApplyDataToComponent uses to look up the matching sjson template. static constexpr std::size_t gui_component_name_offset = 0x4'88; - // Retuning mDef then re-running ComponentData::SetupComponent re-applies the template - this is how a plain button - // is converted into a key-rebind style text row. + // Retuning mDef then re-running SetupComponent re-applies the template. static constexpr std::size_t component_data_offset = 0x88; // GUIComponent::mData (sgg::ComponentData). static constexpr std::size_t component_def_offset = 0xA8; // mData(0x88) + ComponentData::mDef(0x20). - // Field offsets inside sgg::ComponentDataDef (relative to component_def_offset). static constexpr std::size_t def_use_text_area = 0x05; // mUseTextArea (bool) static constexpr std::size_t def_add_text_area = 0x06; // mAddTextArea (bool) static constexpr std::size_t def_deselect_on_mouse_off = 0x13; // mDeselectOnMouseOff (bool) @@ -58,9 +55,7 @@ namespace big::mod_settings static constexpr std::size_t def_graphic = 0x80; // mGraphic (HashGuid) static constexpr std::size_t def_selected_graphic = 0x84; // mSelectedGraphic (HashGuid) static constexpr std::size_t def_alternate_graphic = 0x88; // mAlternateGraphic (HashGuid) - // Each sgg::SoundCue is 0x10 bytes (pOwner @0, mName HashGuid @8). The base OnClicked plays mPressSound, while the - // native ToggleOptionValueChanged (which our toggle path replaces) plays the toggle cues - so we copy the matching - // one into mPressSound to reproduce the sound. + // Base OnClicked plays mPressSound, so copy the native toggle cue there. static constexpr std::size_t def_press_sound = 0x1'B0; // mPressSound (sgg::SoundCue) static constexpr std::size_t def_toggle_on_sound = 0x1'E0; // mToggleOnSound (sgg::SoundCue) static constexpr std::size_t def_toggle_off_sound = 0x1'F0; // mToggleOffSound (sgg::SoundCue) @@ -79,10 +74,10 @@ namespace big::mod_settings static constexpr std::size_t def_spacing = 0x1'5C; // mSpacing (float) row pitch, read by UpdateScrollState static constexpr std::size_t def_fade_speed = 0x2'1C; // mFadeSpeed (float) opacity ease rate (component +0x2C4) - // GUIComponent::Update moves mFadeOpacity toward mFadeTarget by dt * mFadeSpeed, so this drives the fade timing. + // The ease rate the native option templates use. Text rows carry none of their own, so without this they would + // never fade in. static constexpr float row_fade_speed = 10.0f; - // sgg::MessageDialog, the single-button box the game uses in the MAIN MENU for save/file errors. static constexpr std::size_t message_dialog_size = 0x2'F0; // sizeof sgg::MessageDialog static constexpr std::size_t screen_manager_offset = 0x48; // sgg::GameScreen::mScreenManager static constexpr std::size_t screen_removed_offset = 0x21; // sgg::GameScreen::mRemoved (bool) @@ -92,31 +87,25 @@ namespace big::mod_settings static constexpr std::size_t dialog_confirm_button_offset = 0x1'A0; // sgg::MenuScreen::mConfirmButton static constexpr std::size_t dialog_message_offset = 0x2'B0; // sgg::MessageDialog::mMessageText - // The MessageDialog.sjson MessageText template renders at FontSize 26, too large for the multi-line body. + // MessageDialog.sjson MessageText uses FontSize 26. static constexpr std::size_t textbox_font_handle_offset = 0x6'A4; // GUIComponentTextBox::mFontHandle static constexpr std::size_t font_handle_size_ratio_offset = 0x0C; // sgg::FontHandle::mFontSizeRatio static constexpr std::size_t font_handle_eng_size_ratio_offset = 0x10; // sgg::FontHandle::mEnglishFontSizeRatio static constexpr float restart_message_font_scale = 0.75f; // ~26 -> ~19.5 - // Module-relative RVAs for overloaded functions the PDB map cannot disambiguate. static constexpr std::uintptr_t anchor_rva = 0x11'5C'70; // GUIComponentButton::GUIComponentButton static constexpr std::uintptr_t message_dialog_ctor_rva = 0x16'EE'60; // sgg::MessageDialog::MessageDialog static constexpr std::uintptr_t add_screen_rva = 0x14'7D'D0; // sgg::ScreenManager::AddScreen - // tf_new_internal: the game's own num-box factory. static constexpr std::uintptr_t numbox_factory_rva = 0x17'A5'30; - // eastl::vector::push_back, used only as a fallback when the named PDB symbol is missing. static constexpr std::uintptr_t push_back_rva = 0x14'1E'D0; - // sgg::MenuScreen::TeleportCursorTo(this, GUIComponent*) - the 2-arg overload. static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; - // The config/control globals (ConfigOptions::UseMouse/Language, Controls::Cancel/Select) live in .data/.rdata, - // which a game update can grow and shift independently of .text, so they are resolved by name rather than by an - // anchor-relative RVA. + // Config/control globals move with .data/.rdata, so resolve them by name. - // sgg::GUIComponentNumBox, sizeof 0x5D0. Derives directly from GUIComponent, not GUIComponentButton. + // sgg::GUIComponentNumBox, sizeof 0x5D0. static constexpr std::size_t numbox_value_offset = 0x5'40; // mNumberValue (float) static constexpr std::size_t numbox_step_offset = 0x5'44; // mNumberStepValue (float) static constexpr std::size_t numbox_min_offset = 0x5'48; // mNumberMin (float) @@ -130,38 +119,29 @@ namespace big::mod_settings static constexpr std::size_t numbox_label_text_offset = 0x5'A8; // mTextBox (GUIComponentTextBox*, the label) static constexpr std::size_t numbox_sizeof = 0x5'D0; - // sgg::GUIComponentButton box-graphic scaling. GUIComponentButton::Draw pushes only a uniform scale into it, so a - // non-uniform (wider) box needs the anim's own def mScaleX plus mScaleModifierOnlyX, which the anim draw path honours. - static constexpr std::size_t button_anim_offset = 0x5'70; // GUIComponentButton::mAnim (GUIComponentAnimation*) - // GUIComponentButton::GetArea reads GUIComponentButton::mLabel location, not the button's. + // Wider button boxes need the anim's mScaleX plus mScaleModifierOnlyX. + static constexpr std::size_t button_anim_offset = 0x5'70; // GUIComponentButton::mAnim (GUIComponentAnimation*) static constexpr std::size_t button_label_offset = 0x5'80; // GUIComponentButton::mLabel (GUIComponentTextBox*) static constexpr std::size_t anim_scale_modifier_only_x_offset = 0x5'42; // mScaleModifierOnlyX (bool) - // component_def_scale_* offsets are from the component base. - static constexpr std::size_t component_def_scale_x_offset = 0x1'14; // mData.mDef.mScaleX (float) + static constexpr std::size_t component_def_scale_x_offset = 0x1'14; // mData.mDef.mScaleX (float) static constexpr std::size_t component_def_scale_y_offset = 0x1'18; // mData.mDef.mScaleY (float) - // FreeFormSelectOffset is added to a component's location when the spatial keyboard/controller nav - // (SearchInDirection) evaluates it as a candidate. Used to place the scroll arrows' eval point where the next or - // previous row would be, so nav reaches an arrow at a page edge (see enable_arrow_keyboard_paging). + // SearchInDirection adds FreeFormSelectOffset when evaluating candidates. static constexpr std::size_t component_free_form_offset_x_offset = 0x1'54; // mFreeFormSelectOffsetX (float) static constexpr std::size_t component_free_form_offset_y_offset = 0x1'58; // mFreeFormSelectOffsetY (float) static constexpr std::size_t component_auto_activate_offset = 0x00'BC; // mAutoActivateWithGamepad (bool) - // SearchInDirection skips a candidate whose mFreeFormSelectable is false before it even calls IsSelectable, while - // mouse hover does not read it - so clearing it makes UP/DOWN nav jump a row that the mouse can still hover. + // SearchInDirection reads mFreeFormSelectable before IsSelectable, but mouse hover does not. static constexpr std::size_t component_free_form_selectable_offset = 0x00'B1; // mData.mDef.mFreeFormSelectable (bool) - // The Button_Secondary sprite's native atlas width in px. The box draws at native * mScale * mScaleX. static constexpr float button_graphic_native_width = 350.0f; - // Approximate label capacity of the box at its native width, in measure_width glyph units. static constexpr float button_label_capacity = 15.0f; static constexpr float button_label_padding = 2.0f; - // sgg::GUIComponentSlider, the audio-volume drag bar. DoShowCategory hand-builds it, so make_slider_row does too. - // The vtable is preferred by name. This RVA is a .rdata fallback and must be refreshed when the build changes. + // sgg::GUIComponentSlider is hand-built by DoShowCategory. The vtable RVA is a .rdata fallback. static constexpr std::uintptr_t slider_vtable_rva = 0x4D'8A'68; static constexpr std::size_t slider_sizeof = 0x5'B0; static constexpr std::size_t image_sizeof = 0x5'78; // sgg::GUIComponentImage (mBacking/mFill) @@ -177,23 +157,19 @@ namespace big::mod_settings static constexpr std::size_t slider_value_text_offset = 0x5'98; // mValueTextBox (GUIComponentTextBox*, right value) static constexpr std::size_t slider_fraction_offset = 0x5'A4; // mFraction (float, normalized 0..1 value) - // GUIComponentSlider has no Draw-time highlight gate (unlike GUIComponentButton, whose Draw re-derives it from - // mForceSelected/owner->mSelectedComponent), so the focus look tracks its own mFocused bool. - static constexpr std::size_t slider_focused_offset = 0x5'48; // GUIComponentSlider::mFocused (bool) + // GUIComponentSlider's focus look tracks mFocused directly. + static constexpr std::size_t slider_focused_offset = 0x5'48; // GUIComponentSlider::mFocused (bool) - // Held-input auto-repeat for slider rows, matching the values the native num-box constructor writes into - // mRepeatDelay/mRepeatInterval: the press steps once immediately, then holding repeats at 20 Hz after a pause. - static constexpr float slider_repeat_delay = 0.6f; - static constexpr float slider_repeat_interval = 0.05f; + // Matches the native num-box repeat timings. + static constexpr float slider_repeat_delay = 0.6f; + static constexpr float slider_repeat_interval = 0.05f; static constexpr std::size_t textbox_use_selected_color_off = 0x5'52; // GUIComponentTextBox::mUseSelectedTextColor static constexpr std::size_t vtable_on_mouse_off_offset = 0x00'60; // GUIComponent::OnMouseOff slot static constexpr std::size_t vtable_on_unselected_offset = 0x00'88; // GUIComponent::OnUnselected slot static constexpr std::size_t vtable_on_focus_off_offset = 0x1'18; // GUIComponent::OnFocusOff slot static constexpr std::size_t vtable_set_location_offset = 0x1'80; // GUIComponent::SetLocation slot (moves the component and its children) - // Greying a slider/num-box: the button-style def greying does not reach their separate label/value text boxes or - // bar/arrow graphics, so each is greyed directly. An image tints from mColor every frame, so mColorTarget is written - // as well or a lerp undoes it. + // Button-style def greying does not reach slider/num-box child text or graphics. static constexpr std::size_t textbox_use_disabled_color_off = 0x5'53; // GUIComponentTextBox::mUseDisabledTextColor static constexpr std::size_t textbox_text_red = 0x1'B4; // mData.mDef.mTextRed (float) static constexpr std::size_t textbox_selected_text_red = 0x1'D0; // mData.mDef.mSelectedTextRed (float) @@ -208,15 +184,13 @@ namespace big::mod_settings static constexpr std::size_t def_sel_red = 0xFC; // ComponentDataDef::mSelectedRed - set <0 to disable the selected-colour override in Draw/On(Un)Selected static constexpr float disabled_text_grey = 0.22f; // matches set_def_text_grey (toggle/text rows) static constexpr std::uint32_t disabled_graphic_grey = 0xFF'66'66'66; // opaque 0.4 grey (packed A,B,G,R) - // The template caches a bright colour at build time and greying the def alone does not update it, so a still- - // selectable greyed label stays bright - SetTextColor re-applies the grey, as UpdateButtonStates does. + // Still-selectable greyed labels need SetTextColor because the template caches a bright colour. static constexpr std::size_t vtable_set_text_color_offset = 0x1'60; static constexpr std::uint32_t disabled_label_grey_packed = 0xFF'38'38'38; static constexpr std::size_t animation_color_offset = 0x5'58; // GUIComponentAnimation::mColor (packed ARGB) static constexpr std::uint32_t numbox_hover_bg_black = 0xFF'00'00'00; // the num-box's hovered/selected box colour - // Flags = 0 destructs owned sub-components without the final operator delete, so the block is freed separately. static constexpr std::size_t vtable_deleting_dtor_offset = 0x1'88; using ctor_fn = void* (*)(void* button, void* owner_screen); @@ -239,7 +213,7 @@ namespace big::mod_settings using numbox_set_range_fn = void (*)(void* num_box, float min, float max); using numbox_set_value_fn = void (*)(void* num_box, float value, bool notify); - // GUIComponent-derived constructors take the initial location as a Vec2 passed by value in one 64-bit register. + // GUIComponent-derived constructors take Vec2 by value in one 64-bit register. using gui_component_ctor_fn = void (*)(void* self, std::uint64_t location_packed); using slider_defaults_fn = void (*)(void* slider); using slider_set_fraction_fn = void (*)(void* slider, float fraction, bool notify); @@ -254,7 +228,6 @@ namespace big::mod_settings #pragma region Native bindings, panel model, and menu state - // sgg::HashGuid is a 32-bit interned-string id in its first field. struct HashGuid { std::uint32_t m_id; @@ -262,9 +235,7 @@ namespace big::mod_settings using hash_lookup_fn = HashGuid* (*)(HashGuid * out, const char* str, std::size_t len); - // sgg::ProfileManager::SaveProfile(eastl::string* profileName, bool showSpinner, bool async): serializes the active - // profile (language, audio volumes, resolution/window/VSync/graphics, and all gameplay/interface/accessibility - // toggles) to disk. Called synchronous (async=false) to guarantee the write completes before we force a restart. + // SaveProfile is called synchronous so native settings are written before a forced restart. using save_profile_fn = char (*)(void* profile_name, bool show_spinner, bool async); static ctor_fn g_button_ctor = nullptr; @@ -292,161 +263,131 @@ namespace big::mod_settings static gui_component_ctor_fn g_textbox_ctor = nullptr; static slider_defaults_fn g_slider_defaults = nullptr; static slider_set_fraction_fn g_slider_set_fraction = nullptr; - static std::uintptr_t g_slider_vtable = 0; // resolved slider vftable address - // A patched copy of the slider vtable (built in set_up_hooks) whose GetArea/GetScreenArea slots return a one-row - // hit rect (see row_bounded_area), replacing the native ones that union the slider's sub-components into a - // screen-spanning rect. 128 slots comfortably covers the class's virtual table. + static std::uintptr_t g_slider_vtable = 0; + // Native slider GetArea unions its sub-components into a screen-spanning rect. static constexpr std::size_t slider_vtable_slot_count = 128; - // The highest slot we override or copy through is SetLocation at +0x180, so keep the buffer big enough for it. static_assert(0x1'80 / sizeof(std::uintptr_t) < slider_vtable_slot_count, "vtable copy buffer too small for the highest patched slot"); static std::uintptr_t g_slider_vtable_copy[slider_vtable_slot_count] = {}; - static std::uintptr_t g_slider_vtable_patched = 0; // address of the patched copy above + static std::uintptr_t g_slider_vtable_patched = 0; - // A patched copy of the GUIComponentButton vtable (built lazily in install_wide_button_nav_rect from the first action - // button's vtable) whose GetArea/GetScreenArea slots return the same wide one-row rect (row_bounded_area), so a - // centre-column action button is reachable by the vertical spatial nav. Every other button row keeps the native - // vtable. + // Centre-column action buttons need a wide one-row GetArea for vertical spatial nav. static std::uintptr_t g_button_vtable_copy[slider_vtable_slot_count] = {}; - static std::uintptr_t g_button_vtable_patched = 0; // address of the patched copy above - static teleport_cursor_fn g_teleport_cursor = nullptr; // drops the controller cursor on a row (initial focus) - static set_mouse_over_fn g_set_mouse_over = nullptr; // MenuScreen::SetMouseOver (highlight + select a row) - static const bool* g_use_mouse = nullptr; // sgg::ConfigOptions::UseMouse (false in controller mode) - static const char* g_config_language = nullptr; // sgg::ConfigOptions::Language - - static component_focused_fn g_component_focused = nullptr; // focuses a row so it receives stick input + green - static input_get_state_fn g_input_get_state = nullptr; // reads a remappable control's per-frame state - static mouse_button_down_fn g_mouse_button_down = nullptr; // true while a mouse button is held (active drag detect) - static input_dir_pressed_fn g_input_was_left_pressed = nullptr; // left/decrease press edge (dpad, arrow, stick) - static input_dir_pressed_fn g_input_was_right_pressed = nullptr; // right/increase press edge - static input_dir_pressed_fn g_input_is_left_pressed = nullptr; // left/decrease held (level, not just the edge) - static input_dir_pressed_fn g_input_is_right_pressed = nullptr; // right/increase held - static const void* g_controls_cancel = nullptr; // &sgg::Controls::Cancel (controller B/keyboard Esc) - static const void* g_controls_select = nullptr; // &sgg::Controls::Select (controller A/Enter) - static save_profile_fn g_save_profile = nullptr; // sgg::ProfileManager::SaveProfile (flush native settings) - static void* g_active_profile = nullptr; // &sgg::ProfileManager::ACTIVE_PROFILE - - // Set true by register_hooks only once every engine symbol, RVA and offset the Mods tab needs has resolved. + static std::uintptr_t g_button_vtable_patched = 0; + static teleport_cursor_fn g_teleport_cursor = nullptr; + static set_mouse_over_fn g_set_mouse_over = nullptr; + static const bool* g_use_mouse = nullptr; + static const char* g_config_language = nullptr; + + static component_focused_fn g_component_focused = nullptr; + static input_get_state_fn g_input_get_state = nullptr; + static mouse_button_down_fn g_mouse_button_down = nullptr; + static input_dir_pressed_fn g_input_was_left_pressed = nullptr; + static input_dir_pressed_fn g_input_was_right_pressed = nullptr; + static input_dir_pressed_fn g_input_is_left_pressed = nullptr; + static input_dir_pressed_fn g_input_is_right_pressed = nullptr; + static const void* g_controls_cancel = nullptr; + static const void* g_controls_select = nullptr; + static save_profile_fn g_save_profile = nullptr; + static void* g_active_profile = nullptr; + static bool g_feature_enabled = false; - // sgg::KeyboardButtonId values used for edit confirm/cancel (validated in the PDB). + // sgg::KeyboardButtonId values, validated in the PDB. static constexpr int key_escape = 0; static constexpr int key_kp_enter = 113; static constexpr int key_return = 127; - // Hash of the game's "Blank" graphic, used to hide a row's button background. static std::uint32_t g_blank_graphic = 0; - // Panel layout, in native 1080p menu coordinates. The engine's UpdateScrollState pass positions each on-page row at Y - // = (index - pageStart) * row_pitch + row_base_y + ScreenCenterOffsetY, and X = the row's own location. - static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton - static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column - static constexpr float value_text_offset_x = 15.0f; // right-justify the value, aligning it with the toggle column - static constexpr float numbox_location_x = 1365.0f; // native OptionNumBox X (box + arrows clear the scrollbar) - static constexpr float slider_location_x = 1330.0f; // native OptionSlider X (bar + value clear the scrollbar) + // Native 1080p menu coordinates. UpdateScrollState uses row_base_y and row_pitch for page layout. + static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton + static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column + static constexpr float value_text_offset_x = 15.0f; // right-justify the value, aligning it with the toggle column + static constexpr float numbox_location_x = 1365.0f; // native OptionNumBox X (box + arrows clear the scrollbar) + static constexpr float slider_location_x = 1330.0f; // native OptionSlider X (bar + value clear the scrollbar) static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) static constexpr float row_base_y = 300.0f; // first row's Y - matches the vanilla option templates static constexpr float row_pitch = 45.0f; // vertical distance between rows (vanilla Spacing = 45) static constexpr std::uint32_t rows_per_page = 10; // vanilla ItemsPerPage = 10 - // Action-button rows use the taller Button_Secondary box, so apply_button_spacing adds breathing room around them. static constexpr float button_extra_lead = 14.0f; static constexpr float button_extra_trail = 14.0f; - // Both rom.mod_settings.load and Chalk bind a mod's settings under the root "config" section. static const std::string root_section = "config"; - // Chalk writes a placeholder entry with this key per section so empty groups persist. Skip it. + // Chalk writes this placeholder per section so empty groups persist. static constexpr const char* section_empty_key = "..."; - // Approximate visual width budget for the right-column value. The menu font is variable-width, so budget by summed - // glyph weight instead of raw character count. + // Approximate right-column value width in glyph weights. static constexpr float value_display_max_width = 30.0f; - // Edit-cursor blink half-period (ms): the "|" shows for this long, then hides. static constexpr std::uint64_t edit_cursor_blink_ms = 500; - // What a panel row represents, so a click can be routed to the right action. enum class RowKind { - mod_entry, // opens that mod's settings - group, // opens a nested config group (a child section) - setting, // edits one config entry - action, // a button that runs an action (e.g. Apply/Reset) - info, // a read-only virtual row (value from a Lua get/text callback, no config entry) + mod_entry, + group, + setting, + action, + info, }; struct PanelRow { GUIComponent* component = nullptr; RowKind kind = RowKind::mod_entry; - std::string stem; // owning mod's config-file stem - std::string setting_key; // config entry key (setting rows only) + std::string stem; + std::string setting_key; - // The bound config entry, valid for the config file's lifetime. toml_v2::config_file::config_entry_base* entry = nullptr; - bool disabled = false; // greyed & non-interactable (mod disabled) - bool is_enabled_toggle = false; // the mod's master "enabled" toggle - bool is_virtual_input = false; // an interactive virtual row (value via Lua get/set, not a config entry) - bool is_toggle = false; // a boolean toggle row (config bool, or an interactive virtual bool) + bool disabled = false; + bool is_enabled_toggle = false; + bool is_virtual_input = false; + bool is_toggle = false; - // The on/off state a virtual toggle was drawn with. A virtual toggle computes its flip from get(), but get() - // may return nil (the mod's value is not set yet, the case `type` covers) - the flip then falls back to this - // last-drawn state so the first click still works. Only meaningful for an interactive virtual bool row. + // Fallback flip state when a virtual toggle's get() returns nil. bool toggle_value = false; - // Author-provided description shown while this row is highlighted. std::string description; - // Right-column value display for a non-bool setting row, positioned to follow `component` each frame. + // Mirrors component position each frame. GUIComponent* value_component = nullptr; - // Bounded number setting, rendered as a native slider and snapped to stepper_step. bool is_slider = false; bool is_stepper = false; double stepper_min = 0.0; double stepper_max = 0.0; double stepper_step = 1.0; - // Number-display options for slider value text. bool show_as_percentage = false; bool is_percentage = false; - // Enum row, rendered as a native num-box whose value text is overridden to the label. bool is_enum = false; std::vector enum_values; std::vector enum_labels; std::string target_section; - // The entry's REAL config section (for virtual-row Lua I/O: get/set/text). A `group` override can place a row on a - // menu page whose path differs from the entry's config section, so runtime commits must use this, not the view path. + // Real config section for virtual-row Lua I/O, which may differ from the view path. std::string config_section; }; static std::vector g_rows; - // Set when a restart-required setting is changed this menu session. On options-menu close we warn and close the game. static bool g_restart_required = false; - // The restart-causing changes this session, keyed by "\0
\0" so re-editing the same setting - // overwrites its line rather than adding a duplicate. Values are the human-readable lines listed in the restart - // popup, e.g. "MyMod: Enabled (on)". + // Restart-causing changes keyed by setting, so re-editing overwrites its popup line. static std::map g_restart_changes; - // Baseline value for each touched restart-required setting, used to drop changes that were reverted. static std::map g_restart_baselines; - // The native restart message box's only button. Clicking it closes the game. static GUIComponent* g_restart_confirm_button = nullptr; - // The restart message box itself, used to distinguish its button from rebuilt option rows. static void* g_restart_dialog = nullptr; - // True once the restart prompt has been shown this menu session (so closing again proceeds). static bool g_restart_prompt_shown = false; - // Current Mods panel view plus a deferred navigation request applied from the Update hook. enum class View { mod_list, @@ -454,15 +395,14 @@ namespace big::mod_settings }; static View g_view = View::mod_list; - static std::string g_view_stem; // mod whose settings are shown (mod_settings view) - static std::string g_view_section; // config section shown within that mod (mod_settings view) + static std::string g_view_stem; + static std::string g_view_section; static bool g_nav_pending = false; static View g_pending_view = View::mod_list; static std::string g_pending_stem; static std::string g_pending_section; - // Identifies a panel row by its stable fields (kind + owning mod + section + key) so it can be matched to the - // equivalent freshly built row after a rebuild frees every component. + // Stable row identity for matching after a rebuild frees components. struct RowIdentity { bool valid = false; @@ -470,27 +410,21 @@ namespace big::mod_settings std::string stem; std::string section; std::string key; - std::string config_section; // real config section, to distinguish same-named keys grouped onto one page + std::string config_section; }; - // The clicked row to hold as hovered/selected across a click-triggered instant rebuild. static RowIdentity g_keep_active_row; - // Frames to re-assert the clicked row as hovered/selected after a click-triggered instant rebuild. The native hover - // pass runs the frame after the rebuild and can transiently resolve the stationary cursor to a neighbouring row (or - // clear the bottom-prompt label), so the prompt, description and highlight blink for a frame unless we hold them. + // Native hover can resolve the stationary cursor a frame late after a rebuild. static constexpr int keep_active_frame_count = 3; static int g_keep_active_frames = 0; - // Seconds of input quiet after a numeric setting changes before dynamic rows are rebuilt. static constexpr float dynamic_refresh_settle_seconds = 0.15f; - // Time left on that debounce. A slider fires every frame while dragged, so rebuild only after a quiet gap. + // Sliders fire every frame while dragged, so rebuild only after a quiet gap. static float g_dynamic_refresh_settle = 0.0f; - // Navigation restore stack: one entry per drill-in level. Each records the parent view's scroll offset and which row - // was drilled through, so backing out restores that scroll and re-selects that row instead of snapping to the top. - // focus_stem identifies a mod row, focus_section a group row by its target section. + // Restore stack for backing out without losing scroll or focus. struct NavRestore { std::uint32_t scroll_index = 0; @@ -502,19 +436,18 @@ namespace big::mod_settings static NavRestore g_pending_restore; static bool g_has_pending_restore = false; - // Freetext edit state. Typed input is captured in the window procedure and applied on the game thread. + // Typed input is captured in the window procedure and applied on the game thread. static bool g_editing = false; static GUIComponent* g_edit_component = nullptr; static toml_v2::config_file::config_entry_base* g_edit_entry = nullptr; static std::string g_edit_buffer; - static std::size_t g_edit_cursor = 0; // caret position as a byte index into g_edit_buffer. - static bool g_edit_numeric = false; // restrict input to a numeric literal. + static std::size_t g_edit_cursor = 0; + static bool g_edit_numeric = false; static bool g_edit_confirm = false; static bool g_edit_cancel = false; - // Turns a config-file stem ("AuthorName-ModName") into a display name using the setting-key friendly-name logic. - static std::string key_to_display(const std::string& key); // shared friendly-name logic, defined below + static std::string key_to_display(const std::string& key); #pragma endregion @@ -527,8 +460,6 @@ namespace big::mod_settings return key_to_display(name); } - // The mod's Thunderstore manifest description, shown in the description box while its row in the mod list is - // highlighted. static std::string mod_description_from_stem(const std::string& stem) { if (!big::g_lua_manager) @@ -546,26 +477,21 @@ namespace big::mod_settings return {}; } - // Description-box note for a mod that opted out of the in-game settings menu. static std::string opt_out_note() { return "This mod opted out of the in-game settings menu. Check the mod page for how to " "configure it, if applicable."; } - static std::string resolve_localized(const localized_text& t); // defined below + static std::string resolve_localized(const localized_text& t); - // Description for an opted-out mod's greyed row, preferring the author's localized note. static std::string opt_out_description(const std::string& stem) { const std::string custom = resolve_localized(mod_opt_out_description(stem)); return !custom.empty() ? custom : opt_out_note(); } - // Escapes the characters GUIComponentTextBox::Parse treats as markup, so arbitrary user text renders verbatim. - // The parser reads '\' as an escape lead that consumes the following word ("C:\Program..." -> "C: ...") and '[' ']' - // as inline-tag delimiters whose contents are dropped ("[deprecated] x" -> " x"). Backslash must be escaped first. - // '{' and '@' are also markup leads but have no literal escape and do not eat surrounding characters, so are left. + // Parse treats backslash and square brackets as markup. Backslash must be escaped first. static std::string escape_markup(const std::string& text) { std::string out; @@ -608,16 +534,15 @@ namespace big::mod_settings return a.size() < b.size() ? -1 : 1; } - // Approximate width of a UTF-8 byte in the value font, in the same units as value_display_max_width. static float glyph_weight(unsigned char c) { if (c >= 0xC0) { - return 1.0f; // UTF-8 lead byte: count the codepoint once + return 1.0f; } if (c >= 0x80) { - return 0.0f; // UTF-8 continuation byte + return 0.0f; } switch (c) { @@ -642,13 +567,13 @@ namespace big::mod_settings case 'I': case 'f': case 't': - case 'r': return 0.5f; // narrow glyphs + case 'r': return 0.5f; case 'm': case 'w': case 'M': case 'W': case '@': - case '%': return 1.5f; // wide glyphs + case '%': return 1.5f; default: return 1.0f; } } @@ -663,15 +588,12 @@ namespace big::mod_settings return w; } - // True for a "word" byte: ASCII alphanumeric, underscore, or any UTF-8 byte (>=0x80, so non-ASCII letters count as - // word characters). Used for Ctrl+Left/Right word skip. static bool is_word_byte(char c) { const unsigned char u = static_cast(c); return (u >= '0' && u <= '9') || (u >= 'A' && u <= 'Z') || (u >= 'a' && u <= 'z') || u == '_' || u >= 0x80; } - // Caret one codepoint to the left, skipping UTF-8 continuation bytes. static std::size_t caret_prev(const std::string& s, std::size_t pos) { if (pos == 0) @@ -700,7 +622,6 @@ namespace big::mod_settings return pos; } - // Caret to the start of the current/previous word for Ctrl+Left. static std::size_t caret_prev_word(const std::string& s, std::size_t pos) { while (pos > 0 && !is_word_byte(s[pos - 1])) @@ -714,7 +635,6 @@ namespace big::mod_settings return pos; } - // Caret to the start of the next word for Ctrl+Right. static std::size_t caret_next_word(const std::string& s, std::size_t pos) { const std::size_t n = s.size(); @@ -729,14 +649,13 @@ namespace big::mod_settings return pos; } - // Caps an over-wide value string by summed glyph width so it does not run left into the option's key label. static std::string truncate_value(const std::string& text) { if (measure_width(text) <= value_display_max_width) { return text; } - const float avail = value_display_max_width - measure_width("..."); // leave room for the prefix + const float avail = value_display_max_width - measure_width("..."); std::size_t start = text.size(); float used = 0.0f; while (start > 0) @@ -796,12 +715,10 @@ namespace big::mod_settings } std::memset(bytes, 0, 24); std::memcpy(bytes, text, n); - bytes[0x17] = static_cast(0x17 - n); // SSO: remaining = capacity(23) - length + bytes[0x17] = static_cast(0x17 - n); } - // GUI objects are allocated and freed through the GAME's CRT, never H2M's: H2M is /MT while the game is /MD against - // ucrtbase, and the engine frees anything it owns (removed screens, a slider's sub-components, tf_new_internal - // blocks) with ucrtbase's _aligned_free. + // Engine-owned GUI objects must use the game's CRT heap, not H2M's /MT CRT. using aligned_malloc_fn = void*(__cdecl*)(std::size_t, std::size_t); using aligned_free_fn = void(__cdecl*)(void*); @@ -839,7 +756,7 @@ namespace big::mod_settings return row; } - // Links a finished row into the drawn and paged vectors. Off-page rows start transparent to avoid flashing at the top. + // Off-page rows start transparent to avoid flashing at the top. static void finalize_row(MiscSettingsScreen* screen, GUIComponent* row, bool in_options = true) { GUIComponent* value = row; @@ -856,7 +773,6 @@ namespace big::mod_settings *reinterpret_cast(reinterpret_cast(row) + component_def_offset + def_fade_speed) = row_fade_speed; } - // Shows the on or off toggle graphic from the OptionToggleButton template. static void set_toggle_graphic(GUIComponent* row, bool is_on) { if (!g_set_normal_texture) @@ -869,7 +785,7 @@ namespace big::mod_settings g_set_normal_texture(row, is_on ? on_hash : off_hash, false); } - // Reproduces the vanilla toggle click sound by copying the cue for the value the click will produce into mPressSound. + // Copy the produced value's cue into mPressSound for vanilla toggle audio. static void stage_toggle_press_sound(GUIComponent* row, bool new_value) { char* def = reinterpret_cast(row) + component_def_offset; @@ -877,8 +793,7 @@ namespace big::mod_settings std::memcpy(def + def_press_sound, def + src, sound_cue_size); } - // Dims a row's def text colours so a disabled row reads as greyed out and does not - // recolour on hover. Must be applied before SetupComponent so the change reaches the text box. + // Must run before SetupComponent so the text box receives the greyed colours. static void set_def_text_grey(GUIComponent* row) { char* def = reinterpret_cast(row) + component_def_offset; @@ -891,7 +806,6 @@ namespace big::mod_settings *reinterpret_cast(def + def_sel_text_blue) = grey; } - // Greys a child GUIComponentTextBox with the same colour used by set_def_text_grey. static void grey_text_box(void* text_box) { if (!text_box) @@ -905,7 +819,7 @@ namespace big::mod_settings *reinterpret_cast(b + textbox_disabled_text_alpha) = 1.0f; *reinterpret_cast(b + textbox_use_disabled_color_off) = true; - // Still-selectable greyed rows keep mIsUseable=1, so grey the normal and selected colours too. + // Still-selectable greyed rows keep mIsUseable=1. for (const std::size_t base : {textbox_text_red, textbox_selected_text_red}) { *reinterpret_cast(b + base + 0x0) = disabled_text_grey; @@ -914,7 +828,7 @@ namespace big::mod_settings } } - // Dims a GUIComponentImage to the disabled grey. mColorTarget is written too so the per-frame lerp does not undo it. + // mColorTarget must be written too or the per-frame lerp undoes the grey. static void grey_image(void* image) { if (!image) @@ -926,7 +840,6 @@ namespace big::mod_settings *reinterpret_cast(b + image_color_target_offset) = disabled_graphic_grey; } - // Greys a disabled toggle's bare on/off texture from frame one and disables the selected-colour override. static void grey_toggle_graphic(GUIComponent* row) { char* b = reinterpret_cast(row); @@ -935,11 +848,10 @@ namespace big::mod_settings *reinterpret_cast(b + component_def_offset + def_sel_red) = -1.0f; } - // Sets a row's normal text colour to the native settings-option grey used by OptionToggleButton/OptionNumBox rows. static void set_def_text_normal(GUIComponent* row, bool also_selected = false) { char* def = reinterpret_cast(row) + component_def_offset; - constexpr float option_grey = 0.55f; // matches MiscSettingsScreen.sjson option rows + constexpr float option_grey = 0.55f; *reinterpret_cast(def + def_text_red) = option_grey; *reinterpret_cast(def + def_text_green) = option_grey; *reinterpret_cast(def + def_text_blue) = option_grey; @@ -964,17 +876,17 @@ namespace big::mod_settings g_apply_data(reinterpret_cast(screen), row); char* def = row_bytes + component_def_offset; - *reinterpret_cast(def + def_add_text_area) = 1; // hit area follows the text - *reinterpret_cast(def + def_use_text_area) = 0; // (union with the empty graphic area) - *reinterpret_cast(def + def_graphic) = 0; // no button background - *reinterpret_cast(def + def_selected_graphic) = 0; // no highlight box (text recolours instead) + *reinterpret_cast(def + def_add_text_area) = 1; + *reinterpret_cast(def + def_use_text_area) = 0; + *reinterpret_cast(def + def_graphic) = 0; + *reinterpret_cast(def + def_selected_graphic) = 0; *reinterpret_cast(def + def_alternate_graphic) = 0; - *reinterpret_cast(def + def_width) = 0.0f; // let the text drive the area + *reinterpret_cast(def + def_width) = 0.0f; *reinterpret_cast(def + def_height) = 0.0f; - *reinterpret_cast(def + def_text_justification) = 0; // sgg::Justification::LEFT + *reinterpret_cast(def + def_text_justification) = 0; *reinterpret_cast(def + def_text_offset_x) = row_text_offset_x; - *reinterpret_cast(def + def_y) = row_base_y; // read by UpdateScrollState - *reinterpret_cast(def + def_spacing) = row_pitch; // read by UpdateScrollState + *reinterpret_cast(def + def_y) = row_base_y; + *reinterpret_cast(def + def_spacing) = row_pitch; if (disabled) { @@ -990,8 +902,7 @@ namespace big::mod_settings g_setup_component(row, row_bytes + component_data_offset); } - // SetupComponent applies our zeroed graphic fields but does not actively tear down the normal/selected textures - // a prior template already set. Clear them explicitly. + // SetupComponent does not clear textures a prior template already set. if (g_set_normal_texture) { g_set_normal_texture(row, 0, false); @@ -1019,7 +930,6 @@ namespace big::mod_settings return row; } - // A toggle row: a left-justified label plus the native on/off toggle graphic. static GUIComponent* make_toggle_row(MiscSettingsScreen* screen, const char* label, bool is_on, bool disabled = false, bool block_input = true) { auto* row = create_button(screen); @@ -1036,7 +946,6 @@ namespace big::mod_settings *reinterpret_cast(def + def_y) = row_base_y; *reinterpret_cast(def + def_spacing) = row_pitch; - // Greying needs a SetupComponent pass to reach the text box and button colour. if (disabled) { set_def_text_grey(row); @@ -1063,7 +972,6 @@ namespace big::mod_settings if (block_input && g_disable) { - // Whole-mod-off toggles drop mIsUseable so nav and hover skip the row. g_disable(row); } } @@ -1072,8 +980,7 @@ namespace big::mod_settings return row; } - // A centered native button row for actions. - static void install_wide_button_nav_rect(GUIComponent* row); // defined below (near row_bounded_area) + static void install_wide_button_nav_rect(GUIComponent* row); static GUIComponent* make_button_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true) { @@ -1087,7 +994,6 @@ namespace big::mod_settings set_sso_string(row_bytes + gui_component_name_offset, "CategoryOptionsButton"); g_apply_data(reinterpret_cast(screen), row); - // Stretch the box only enough to fit a label wider than the native box. const float box_scale_x = std::max(1.0f, (measure_width(label) + button_label_padding) / button_label_capacity); constexpr float button_scale = 0.8f; @@ -1095,15 +1001,13 @@ namespace big::mod_settings char* def = row_bytes + component_def_offset; *reinterpret_cast(def + def_y) = row_base_y; *reinterpret_cast(def + def_spacing) = row_pitch; - *reinterpret_cast(def + def_offset_y) = 0.0f; // drop the template's built-in vertical offset - *reinterpret_cast(def + def_scale) = button_scale; // shrink slightly for top/bottom breathing room + *reinterpret_cast(def + def_offset_y) = 0.0f; + *reinterpret_cast(def + def_scale) = button_scale; - // The hover/click rect is GetArea = mCustomWidth * mScale@0x38 * mScaleX@0x114. The drawn box already reflects - // button_scale and mScaleX@0x114 carries box_scale_x below, so mCustomWidth is the plain native width. + // GetArea multiplies mCustomWidth by mScale@0x38 and mScaleX@0x114. *reinterpret_cast(def + def_width) = button_graphic_native_width; *reinterpret_cast(def + def_height) = 58.0f; - // mDeselectOnMouseOff makes action buttons read as momentary instead of staying lit like category tabs. *reinterpret_cast(def + def_deselect_on_mouse_off) = true; if (disabled) @@ -1116,9 +1020,7 @@ namespace big::mod_settings g_setup_component(row, row_bytes + component_data_offset); } - // SetupComponent copied the button def (with the native mCustomWidth used for the hit rect) into the child - // label, whose own def mWidth drives where the text wraps. For a stretched box widen the label's copy to the - // full visible width so a long label stays on one line instead of wrapping at the native width. + // The child label has its own def mWidth, which controls wrapping. if (auto* label_box = *reinterpret_cast(row_bytes + button_label_offset)) { *reinterpret_cast(label_box + component_def_offset + def_width) = button_graphic_native_width * box_scale_x; @@ -1129,7 +1031,6 @@ namespace big::mod_settings g_set_label(row, label); } - // mScaleModifierOnlyX makes GUIComponentAnimation::Draw honour the anim's own horizontal mScaleX. if (box_scale_x > 1.0f) { *reinterpret_cast(row_bytes + component_def_scale_x_offset) = box_scale_x; @@ -1149,7 +1050,7 @@ namespace big::mod_settings g_disable(row); } - // CategoryOptionsButton leaves mFreeFormSelectable unset, so opt enabled action buttons into vertical nav. + // CategoryOptionsButton leaves mFreeFormSelectable unset. if (!disabled) { *reinterpret_cast(row_bytes + component_free_form_selectable_offset) = true; @@ -1162,7 +1063,6 @@ namespace big::mod_settings return row; } - // A right-justified, non-interactive value label paired with a left-column key row. static GUIComponent* make_value_display(MiscSettingsScreen* screen, const char* text, bool disabled) { auto* row = create_button(screen); @@ -1176,14 +1076,14 @@ namespace big::mod_settings g_apply_data(reinterpret_cast(screen), row); char* def = row_bytes + component_def_offset; - *reinterpret_cast(def + def_add_text_area) = 0; // display only: no hit area + *reinterpret_cast(def + def_add_text_area) = 0; *reinterpret_cast(def + def_use_text_area) = 0; *reinterpret_cast(def + def_graphic) = 0; *reinterpret_cast(def + def_selected_graphic) = 0; *reinterpret_cast(def + def_alternate_graphic) = 0; *reinterpret_cast(def + def_width) = 0.0f; *reinterpret_cast(def + def_height) = 0.0f; - *reinterpret_cast(def + def_text_justification) = 1; // sgg::Justification::RIGHT + *reinterpret_cast(def + def_text_justification) = 1; *reinterpret_cast(def + def_text_offset_x) = value_text_offset_x; *reinterpret_cast(def + def_y) = row_base_y; *reinterpret_cast(def + def_spacing) = row_pitch; @@ -1218,9 +1118,9 @@ namespace big::mod_settings g_set_label(row, text); } - row->m_can_be_focused = false; // never interactive, the empty hit area blocks hover/click + row->m_can_be_focused = false; - finalize_row(screen, row, false); // drawn (mComponents) but not paged (mOptions) + finalize_row(screen, row, false); return row; } @@ -1229,7 +1129,6 @@ namespace big::mod_settings return std::isfinite(v) && v == std::floor(v); } - // Overrides a num-box's centered value text with an escaped enum option label. static void set_numbox_value_text(GUIComponent* numbox, const char* text) { if (!g_show_text || !numbox) @@ -1242,7 +1141,6 @@ namespace big::mod_settings } } - // A native num-box stepper row, as used by the game's own FPS-limit and graphics-quality options. static GUIComponent* make_numbox_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool disabled, const std::vector* value_labels = nullptr, bool block_input = true) { if (!g_numbox_factory || !g_numbox_set_range || !g_numbox_set_value || !g_apply_data || !g_show_text) @@ -1258,7 +1156,6 @@ namespace big::mod_settings } char* nb_bytes = reinterpret_cast(nb); - // Name the box and its sub-components so ApplyDataToComponent applies the matching sjson templates. set_sso_string(nb_bytes + gui_component_name_offset, "OptionNumBox"); if (void* value_tb = *reinterpret_cast(nb_bytes + numbox_value_text_offset)) { @@ -1273,17 +1170,18 @@ namespace big::mod_settings set_sso_string(static_cast(right_arrow) + gui_component_name_offset, "OptionNumBoxRightArrow"); } + // mIsInteger picks the value-text format and the input path (one step per press instead of an analog repeat). const bool is_integer = is_whole(min_v) && is_whole(max_v) && is_whole(step_v); *reinterpret_cast(nb_bytes + numbox_is_integer_offset) = is_integer; + // SetRange derives its own step and overwrites mNumberStepValue, so pin ours after it. A zero step would + // freeze the box, and SetRange never clamps the current value, which the SetNumberValue below does. g_numbox_set_range(nb, static_cast(min_v), static_cast(max_v)); *reinterpret_cast(nb_bytes + numbox_step_offset) = static_cast(step_v != 0.0 ? step_v : 1.0); g_apply_data(reinterpret_cast(screen), nb); - // ApplyDataToComponent copies the OptionNumBox template's own row grid (Y=300, Spacing=45) into the component, - // so override it with ours or the box draws on top of the previous row. def_y/def_spacing are the baseY and - // pitch that UpdateScrollState reads. + // ApplyDataToComponent copies OptionNumBox's own row grid, so override it. { char* def = nb_bytes + component_def_offset; *reinterpret_cast(def + def_y) = row_base_y; @@ -1295,7 +1193,7 @@ namespace big::mod_settings g_show_text(label_tb, label); } - // notify = false, or the SetNumberValue hook would persist this initial paint as a user edit. + // notify=false avoids persisting the initial paint as a user edit. g_numbox_set_value(nb, static_cast(initial), false); if (value_labels && !value_labels->empty()) @@ -1314,7 +1212,6 @@ namespace big::mod_settings if (disabled) { - // mDisableInput gates num-box input. mIsUseable only controls nav and hover. *reinterpret_cast(nb_bytes + numbox_disable_input_offset) = true; grey_text_box(*reinterpret_cast(nb_bytes + numbox_label_text_offset)); grey_text_box(*reinterpret_cast(nb_bytes + numbox_value_text_offset)); @@ -1329,12 +1226,12 @@ namespace big::mod_settings } finalize_row(screen, nb); - nb->m_location_x = numbox_location_x; // override finalize_row's default so box + arrows clear the scrollbar + nb->m_location_x = numbox_location_x; return nb; } // Formats a numeric setting value for display. The value is rounded to the display step's precision so scaling by 100 - // does not surface floating-point noise, then trailing zeros are trimmed ("53", "0.5", "50%"). + // does not surface floating-point noise, then trailing zeros are trimmed. static std::string format_setting_display(double value, bool show_as_pct, bool is_pct, double step) { double shown = is_pct ? value * 100.0 : value; @@ -1353,11 +1250,11 @@ namespace big::mod_settings const double scale = std::pow(10.0, decimals); shown = std::round(shown * scale) / scale; - std::string out = std::to_string(shown); // fixed 6-decimal form, e.g. "53.000000" + std::string out = std::to_string(shown); if (out.find('.') != std::string::npos) { const std::size_t last = out.find_last_not_of('0'); - out.erase((out[last] == '.') ? last : last + 1); // drop trailing zeros (and a bare '.') + out.erase((out[last] == '.') ? last : last + 1); } if (show_as_pct || is_pct) { @@ -1366,7 +1263,7 @@ namespace big::mod_settings return out; } - // Sets the slider's right-hand value text after native dragging rewrites it to a percentage. + // Native dragging rewrites the value text to a percentage. static void set_slider_value_text(GUIComponent* slider, const char* text) { if (!g_show_text || !slider) @@ -1379,24 +1276,16 @@ namespace big::mod_settings } } - // Row-sized hit rect for rows whose native GetArea is unsuitable, installed via a patched vtable on the GetArea and - // GetScreenArea slots. Two rows need it: sliders (whose GetArea unions their sub-components into a near - // screen-spanning rect that steals hover from every other row) and centred action buttons (whose GetArea is a narrow - // rect at the button centre that a vertical nav ray never crosses). Slider dragging runs through HandleInput, not - // GetArea, so it is unaffected. static void* row_bounded_area(GUIComponent* self, std::int32_t* out) { - const int left = static_cast(row_location_x + row_text_offset_x); // option-name column start (~660) + const int left = static_cast(row_location_x + row_text_offset_x); out[0] = left; out[1] = static_cast(self->m_location_y) - 22; - out[2] = static_cast(row_location_x) + 22 - left; // out to the value column (~922 wide) - out[3] = 44; // one row tall, under row_pitch so no overlap + out[2] = static_cast(row_location_x) + 22 - left; + out[3] = 44; return out; } - // Copies vtable `src` into `dst` and redirects the GetArea (+0x98) and GetScreenArea (+0xA0) slots to - // row_bounded_area, so the row hit- and nav-tests as one option-column row. The copy is byte-identical otherwise, - // so every other virtual (ctor/dtor/Draw/HandleInput/...) behaves as native. Returns dst as a vtable pointer. static std::uintptr_t build_row_area_vtable(std::uintptr_t* dst, std::size_t dst_bytes, std::uintptr_t src) { std::memcpy(dst, reinterpret_cast(src), dst_bytes); @@ -1405,7 +1294,6 @@ namespace big::mod_settings return reinterpret_cast(dst); } - // Installs the patched button vtable so centre-column action buttons are reachable by vertical nav. static void install_wide_button_nav_rect(GUIComponent* row) { if (!g_button_vtable_patched) @@ -1416,9 +1304,7 @@ namespace big::mod_settings *reinterpret_cast(row) = g_button_vtable_patched; } - // A native slider row (the volume-style drag bar) for a bounded numeric setting. The slider stores a normalized - // 0..1 fraction, so [min,max] is mapped onto it and drags are snapped to `step` in the SetFraction hook. The engine - // exposes no factory for this type, so this replicates what DoShowCategory does for the volume rows. + // Slider stores a normalized 0..1 fraction, with drags snapped in the SetFraction hook. static GUIComponent* make_slider_row(MiscSettingsScreen* screen, const char* label, double min_v, double max_v, double step_v, double initial, bool show_as_pct, bool is_pct, bool disabled, bool block_input = true) { if (!g_gui_component_ctor || !g_image_ctor || !g_textbox_ctor || !g_slider_defaults || !g_slider_set_fraction || !g_slider_vtable || !g_apply_data || !g_show_text) @@ -1433,13 +1319,11 @@ namespace big::mod_settings } std::memset(s, 0, slider_sizeof); - // Base GUIComponent constructor (location passed by value, 0 = origin, overridden below by - // ApplyDataToComponent/finalize_row), then install the patched slider vtable (bounded GetArea) over the base - // one, falling back to the unpatched native vtable if the copy was not built. + // Install the bounded GetArea vtable over the base GUIComponent vtable. g_gui_component_ctor(s, 0); *reinterpret_cast(s) = g_slider_vtable_patched ? g_slider_vtable_patched : g_slider_vtable; - // Defaults does not initialise mOnValueChanged or mValueTextBox, so zero them before Defaults runs. + // Defaults does not initialise mOnValueChanged or mValueTextBox. std::memset(s + slider_on_changed_offset, 0, 3 * sizeof(void*)); *reinterpret_cast(s + slider_label_offset) = nullptr; *reinterpret_cast(s + slider_value_text_offset) = nullptr; @@ -1447,8 +1331,7 @@ namespace big::mod_settings g_slider_defaults(s); *reinterpret_cast(s + slider_owner_offset) = screen; - // Four owned sub-components, each allocated then constructed at the origin (as the game does): two images (bar - // background + fill) and two text boxes (left label + right value). + // Construct the four owned sub-components at the origin, matching the game. char* backing = static_cast(game_alloc(image_sizeof)); char* fill = static_cast(game_alloc(image_sizeof)); char* lbl = static_cast(game_alloc(textbox_sizeof)); @@ -1471,12 +1354,9 @@ namespace big::mod_settings *reinterpret_cast(s + slider_label_offset) = lbl; *reinterpret_cast(s + slider_value_text_offset) = val; - // Parent container, matching DoShowCategory. SetParent is a plain setter (it writes mParentContainer), so a - // direct write is equivalent and avoids a vtable call. + // SetParent only writes mParentContainer. *reinterpret_cast(s + slider_parent_offset) = reinterpret_cast(screen) + menu_screen_container_offset; - // Name the slider and its value box so ApplyDataToComponent applies the OptionSlider/OptionSliderValueText - // templates (bar graphics, colours, FadeSpeed and the label styling). set_sso_string(s + gui_component_name_offset, "OptionSlider"); set_sso_string(val + gui_component_name_offset, "OptionSliderValueText"); @@ -1493,7 +1373,7 @@ namespace big::mod_settings g_show_text(label_tb, label); } - // Paint the starting value without notifying so the SetFraction hook does not treat it as a user edit. + // notify=false avoids treating the initial paint as a user edit. const double range = max_v - min_v; const float frac = (range > 0.0) ? static_cast((initial - min_v) / range) : 0.0f; g_slider_set_fraction(s, frac, false); @@ -1502,7 +1382,7 @@ namespace big::mod_settings if (disabled) { - // Grey every visible part explicitly. The native drag path ignores mIsUseable, so HandleInput blocks it separately. + // Native drag ignores mIsUseable, so HandleInput blocks it separately. auto* sc = reinterpret_cast(s); if (block_input) { @@ -1515,7 +1395,7 @@ namespace big::mod_settings } finalize_row(screen, reinterpret_cast(s)); - reinterpret_cast(s)->m_location_x = slider_location_x; // override finalize_row's default + reinterpret_cast(s)->m_location_x = slider_location_x; return reinterpret_cast(s); } @@ -1523,7 +1403,6 @@ namespace big::mod_settings #pragma region Row teardown and mod list - // Removes the first pointer equal to `value` from an eastl vector by shifting the tail down in place. static void vector_erase(sgg::eastl_vector& vec, GUIComponent* value) { for (GUIComponent** it = vec.m_begin; it != vec.m_end; ++it) @@ -1537,7 +1416,7 @@ namespace big::mod_settings } } - // Tears down every custom row we currently own. Our rows are not registered in the reflection helper. + // Our rows are not registered in the reflection helper. static void destroy_rows(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); @@ -1577,7 +1456,7 @@ namespace big::mod_settings if (owns_subcomponents) { - // Num-boxes and sliders destruct through their own vtables so their owned sub-components are freed too. + // Num-box and slider vtables free their owned sub-components. void** vtbl = *reinterpret_cast(comp); auto dtor = reinterpret_cast(vtbl[vtable_deleting_dtor_offset / sizeof(void*)]); dtor(comp, 0); @@ -1598,7 +1477,6 @@ namespace big::mod_settings g_rows.clear(); } - // Level 1: one row per installed mod, sorted by friendly display name. static void build_mod_list(MiscSettingsScreen* screen) { std::vector stems; @@ -1609,7 +1487,6 @@ namespace big::mod_settings continue; } - // H2M's own framework config is not a mod the user configures here. if (cfg->m_config_file_stem_as_str == "Hell2Modding-Hell2Modding-General") { continue; @@ -1620,7 +1497,7 @@ namespace big::mod_settings } } - std::vector> mods; // (display name, stem) + std::vector> mods; mods.reserve(stems.size()); for (const auto& stem : stems) { @@ -1635,7 +1512,7 @@ namespace big::mod_settings for (const auto& [display, stem] : mods) { - // Opted-out mods stay listed so they do not look missing, but their rows are greyed and cannot be opened. + // Opted-out mods stay listed but cannot be opened. const bool opted_out = mod_opted_out(stem); if (auto* row = make_text_row(screen, escape_markup(display).c_str(), opted_out, /*block_input*/ false)) { @@ -1651,7 +1528,6 @@ namespace big::mod_settings #pragma region Value formatting, freetext editing, and commit - // Turns an identifier into a friendly display string: underscores become spaces and word boundaries are split. static std::string key_to_display(const std::string& key) { const auto is_upper = [](char c) @@ -1700,8 +1576,7 @@ namespace big::mod_settings return out; } - // Renders the edit buffer with a caret marker at `cursor`, windowed by visual WIDTH so the caret stays visible and the - // whole string fits the value column (value_display_max_width) without running into the key label. + // Creates a "caret" (|) when editing textboxes static std::string render_edit_display(const std::string& buf, std::size_t cursor, bool blink_on) { const char* caret = blink_on ? "|" : " "; @@ -1711,7 +1586,7 @@ namespace big::mod_settings cursor = len; } - const float caret_w = 0.6f; // reserve a little for the caret glyph + const float caret_w = 0.6f; const float ellipsis_w = measure_width("..."); if (measure_width(buf) + caret_w <= value_display_max_width) @@ -1719,7 +1594,6 @@ namespace big::mod_settings return escape_markup(buf.substr(0, cursor)) + caret + escape_markup(buf.substr(cursor)); } - // Grow a width-budgeted window outward from the caret, left first so a right-aligned field shows preceding context. std::size_t start = cursor; std::size_t end = cursor; float used = caret_w; @@ -1769,7 +1643,6 @@ namespace big::mod_settings return out; } - // Accepts a character into a numeric edit buffer only if the result stays a plausible numeric literal. static bool numeric_char_ok(const std::string& buffer, std::size_t cursor, char c) { if (c >= '0' && c <= '9') @@ -1782,14 +1655,13 @@ namespace big::mod_settings } if (c == '.') { - return buffer.find('.') == std::string::npos; // a single decimal point. + return buffer.find('.') == std::string::npos; } return false; } // Window-procedure callback: while a freetext setting is being edited, capture typed characters and caret movement - // into the edit buffer. A mouse click anywhere commits the edit (Enter/Escape are read from the game input in the - // HandleInput hook, which also blocks the menu from reacting). + // into the edit buffer. A mouse click anywhere submits the edit. static void on_wndproc(HWND, UINT msg, WPARAM wparam, LPARAM) { if (!g_editing) @@ -1799,7 +1671,6 @@ namespace big::mod_settings if (msg == WM_LBUTTONDOWN || msg == WM_RBUTTONDOWN) { - // Clicking away from the edited row submits the current value, like Enter. g_edit_confirm = true; return; } @@ -1845,7 +1716,7 @@ namespace big::mod_settings if (msg == WM_CHAR) { const unsigned c = static_cast(wparam); - if (c < 32 || c >= 127) // control chars handled via WM_KEYDOWN + if (c < 32 || c >= 127) { return; } @@ -1859,7 +1730,6 @@ namespace big::mod_settings } } - // Registers on_wndproc with the framework's window hook once editing needs typed input. static void ensure_wndproc_registered() { static bool registered = false; @@ -1882,7 +1752,7 @@ namespace big::mod_settings g_edit_component = value_component; g_edit_entry = entry; g_edit_buffer = entry ? entry->get_serialized_value() : std::string{}; - g_edit_cursor = g_edit_buffer.size(); // caret starts at the end + g_edit_cursor = g_edit_buffer.size(); g_edit_numeric = entry && entry->type() != typeid(std::string); g_edit_confirm = false; g_edit_cancel = false; @@ -1920,14 +1790,11 @@ namespace big::mod_settings g_restart_baselines.try_emplace(key, entry->get_serialized_value()); } - // The current game display-language folder code (e.g. "en", "zh-TW"), or "" if unavailable. Read from - // sgg::ConfigOptions::Language (an eastl SSO string whose code chars sit at offset 0, null-terminated). static std::string current_language_code() { return g_config_language ? std::string(g_config_language) : std::string(); } - // Resolves a localized string to the current language code, then English, then the unlocalized value, then any entry. static std::string resolve_localized(const localized_text& t) { if (t.empty()) @@ -1936,7 +1803,7 @@ namespace big::mod_settings } if (t.size() == 1) { - return t.begin()->second; // one entry (a plain value, or the only language provided) + return t.begin()->second; } if (const auto it = t.find(current_language_code()); it != t.end()) { @@ -1953,17 +1820,14 @@ namespace big::mod_settings return t.begin()->second; } - // True if the current settings view has any row with a dynamic Lua-function field. static bool g_view_has_dynamic = false; - // A setting's metadata with any dynamic (Lua-function) description fields evaluated against the current game state. - // Records that the view has a dynamic row so a later toggle can rebuild to re-evaluate it. + // Dynamic metadata marks the view so later toggles can re-evaluate it. static std::optional resolved_metadata(const std::string& stem, const std::string& section, const std::string& key) { auto meta = get_setting_metadata(stem, section, key); if (!meta) { - // Virtual-row metadata lives only in the Lua descs registry, so resolve it straight from there. return resolve_setting_metadata(stem, section, key); } if (meta->has_dynamic) @@ -1977,7 +1841,6 @@ namespace big::mod_settings return meta; } - // The friendly display name for a setting: the author's override when provided, otherwise the prettified key. static std::string setting_display_name(const std::string& stem, const std::string& section, const std::string& key) { const auto meta = resolved_metadata(stem, section, key); @@ -1992,8 +1855,8 @@ namespace big::mod_settings } // Records or clears a restart-required setting change after the value has been written. If the new value equals the - // session baseline (e.g. a toggle flipped and flipped back, or a number re-typed to its original), nothing actually - // changed and the setting is dropped from the restart list; otherwise it is recorded. + // baseline (e.g. a toggle flipped and flipped back, or a number re-typed to its original), nothing actually + // changed and the setting is dropped from the restart list. static void note_change_if_restart_required(toml_v2::config_file::config_entry_base* entry, const std::string& new_value_display) { if (!entry || !entry->m_config_file) @@ -2022,15 +1885,12 @@ namespace big::mod_settings g_restart_required = !g_restart_changes.empty(); } - // The config section to use for a row's virtual-row Lua I/O (get/set/text). A `group` override can place a row on a - // menu page whose path differs from where its configDesc lives, so runtime lookups use the row's stored real - // config section, falling back to the current view path for rows built before that field was set. + // Virtual-row Lua I/O uses the stored real config section, not a `group` override's view path. static const std::string& row_io_section(const PanelRow* row) { return !row->config_section.empty() ? row->config_section : g_view_section; } - // Commit helpers for edited rows, with restart-required tracking and dynamic live-refresh arming. static bool commit_row_bool(PanelRow* row, bool v) { bool changed = false; @@ -2095,7 +1955,7 @@ namespace big::mod_settings return changed; } - // `serialized` is the config-serialized value. Virtual set() receives it as a string. + // Virtual set() receives the config-serialized value as a string. static bool commit_row_serialized(PanelRow* row, const std::string& serialized, const std::string& display) { bool changed = false; @@ -2128,9 +1988,6 @@ namespace big::mod_settings return changed; } - // Refreshes a freetext row's right-column value display to show `serialized`, formatted exactly as - // build_mod_settings renders it (width-truncated with a leading ellipsis, then markup-escaped). Used to reflect a - // committed or cancelled edit in place, without a panel rebuild. static void refresh_value_display(GUIComponent* value_component, const std::string& serialized) { if (value_component && g_set_label) @@ -2140,9 +1997,7 @@ namespace big::mod_settings } } - // Commits or cancels a pending edit. Called from the HandleInput hook so it runs on the same frame the triggering - // key/click is swallowed (HandleInput returns true that frame), which prevents a submitting mouse click from also - // activating the row it lands on. Returns true if the edit ended this call. + // Runs on the same frame HandleInput swallows the triggering key or click. static bool commit_or_cancel_edit() { if (g_edit_confirm) @@ -2151,11 +2006,10 @@ namespace big::mod_settings { capture_restart_baseline(g_edit_entry); - // Bad numeric input simply keeps the previous value because set_serialized_value validates before saving. + // Bad numeric input keeps the previous value because set_serialized_value validates before saving. g_edit_entry->set_serialized_value(g_edit_buffer); - // Clamp/snap the typed number to any declared min/max/step. A fully bounded number renders as a slider, - // so this covers partially bounded or stepped ones. set_serialized_value above already parsed it. + // Covers partially bounded or stepped numbers. set_serialized_value already parsed it. if (g_edit_entry->type() == typeid(double)) { const auto meta = resolved_metadata(g_edit_entry->m_config_file->m_config_file_stem_as_str, @@ -2183,7 +2037,7 @@ namespace big::mod_settings { const double base = meta->has_min ? meta->min : 0.0; v = base + std::round((v - base) / meta->step) * meta->step; - v = clamp_range(v); // snapping may overshoot a bound + v = clamp_range(v); } g_edit_entry->set_value_base(v); } @@ -2191,12 +2045,9 @@ namespace big::mod_settings note_change_if_restart_required(g_edit_entry, g_edit_entry->get_serialized_value()); - // Reflect the committed value in the right-hand display in place. Do NOT rebuild the panel here: a rebuild frees - // and recreates every row, which snaps the visible page back to the top while the scrollbar keeps the scrolled - // position, so the rows and the scrollbar desync until the next manual scroll. + // Reflect the committed value in the right-hand display in place. refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); - // Other rows may still key off this value, so dynamic views re-evaluate shortly after. if (g_view_has_dynamic) { g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; @@ -2207,7 +2058,6 @@ namespace big::mod_settings } if (g_edit_cancel) { - // Restore the unchanged value without a rebuild, for the same scroll-preservation reason. if (g_edit_entry) { refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); @@ -2218,7 +2068,6 @@ namespace big::mod_settings return false; } - // Live-updates the edited value display with a movable, blinking caret. static void update_edit_label() { if (g_edit_component && g_set_label) @@ -2233,36 +2082,30 @@ namespace big::mod_settings #pragma region Editability context and menu-path helpers - // True if `key` is the mod's master enable switch ("enabled", any case). static bool is_enabled_key(const std::string& key) { return big::string::to_lower(key) == "enabled"; } - // True if a config entry carries an author-written description string. static bool entry_has_description(const toml_v2::config_file::config_entry_base* entry) { return entry && !entry->m_description.m_description.empty(); } - // True when the options screen was opened during gameplay, false when opened from the main menu. static bool g_opened_in_game = false; - // True when CurrentHubRoom is non-nil, i.e. the player is in the Crossroads rather than in a run. + // CurrentHubRoom is non-nil in the Crossroads, nil in a run. static bool g_in_hub = false; - static bool g_options_screen_open = false; - // True while options-menu edits should notify mods through on_change callbacks. bool on_change_callbacks_enabled() { return g_options_screen_open; } - // The MiscSettingsScreen ctor's "opened from" argument is MainMenuScreen or PauseScreen. static constexpr std::size_t game_screen_get_type_vtable_slot = 10; - static constexpr int screen_type_pause = 0x10'00'03; // sgg::ScreenType::Pause + static constexpr int screen_type_pause = 0x10'00'03; static bool opener_indicates_in_game(void* opened_from) { @@ -2275,7 +2118,6 @@ namespace big::mod_settings return get_type(opened_from) == screen_type_pause; } - // Authors declare editability, but the master "enabled" toggle and restart_required settings are forced to main_menu. static editable_context effective_editable_context(const std::optional& meta, bool is_enabled_toggle) { if (is_enabled_toggle) @@ -2289,20 +2131,17 @@ namespace big::mod_settings return meta ? meta->context : editable_context::any; } - // True when a setting cannot be changed in the current screen context (main-menu vs in-game vs in-hub), so its row - // is shown read-only with an explanatory note instead of an editable widget. static bool is_context_restricted(editable_context ctx) { switch (ctx) { - case editable_context::main_menu: return g_opened_in_game; // main-menu-only, greyed while in a save. - case editable_context::in_save: return !g_opened_in_game; // in-save-only, greyed at the main menu. - case editable_context::in_hub: return !(g_opened_in_game && g_in_hub); // hub-only, greyed at menu/mid-run. - default: return false; // any. + case editable_context::main_menu: return g_opened_in_game; + case editable_context::in_save: return !g_opened_in_game; + case editable_context::in_hub: return !(g_opened_in_game && g_in_hub); + default: return false; } } - // The description-box note for a row blocked by its editable context. static std::string context_note(editable_context ctx) { switch (ctx) @@ -2314,7 +2153,7 @@ namespace big::mod_settings } } - // Description-box text for a context-restricted row: scenario note first, then the row's normal description. + // Description-box text for a context-restricted row: scenario note first, then appending the row's normal description. static std::string note_then_description(const std::string& note, const std::string& description) { if (note.empty()) @@ -2339,11 +2178,8 @@ namespace big::mod_settings return cfg->try_get_entry(def) != nullptr; } - // Menu paths from a `group` override that resolved to neither a config section nor a declared category, already - // warned about (keyed "\0"), so the per-frame rebuild logs each bad target only once. static std::set g_warned_group_overrides; - // The menu path an entry appears at: its `group` override if present, otherwise its own config section. static std::string menu_path_of(const std::string& config_section, const std::vector& group) { if (group.empty()) @@ -2359,7 +2195,6 @@ namespace big::mod_settings return p; } - // The id chain of an author group menu path, i.e. its segments after the root section. Empty for the root itself. static std::vector author_group_path(const std::string& menu_path) { std::vector out; @@ -2378,7 +2213,6 @@ namespace big::mod_settings return out; } - // Finds an author-declared menu group by its full menu path. static const menu_group* find_author_group(const std::vector& tree, const std::string& menu_path) { const std::string prefix = std::string(root_section) + "."; @@ -2412,9 +2246,7 @@ namespace big::mod_settings return found; } - // Resolves an entry's menu path: its `group` override (validated against author groups and the mod's config - // sections) else its config section. A `group` naming neither is logged once and falls back to the config-section - // placement, so a typo leaves the row where its value lives. Shared by the panel builder and Reset so both agree. + // Validates `group` overrides so the panel and Reset resolve the same menu paths. static std::string resolve_entry_menu_path(const std::string& stem, const std::vector& author_groups, toml_v2::config_file* view_cfg, const std::string& csection, const std::vector& group) { if (group.empty()) @@ -2424,9 +2256,9 @@ namespace big::mod_settings const std::string m = menu_path_of(csection, group); if (find_author_group(author_groups, m)) { - return m; // a declared author group (the common case) + return m; } - if (view_cfg) // or an existing config section the row is being merged into + if (view_cfg) { const std::string desc_prefix = m + "."; for (const auto& [k, e] : view_cfg->m_entries) @@ -2445,8 +2277,6 @@ namespace big::mod_settings return csection; } - // True if menu path `p` lies within the current view scope `scope`: the scope page itself or any of its descendant - // subgroups. Used to limit Reset to the drilled-in group (and its subgroups) rather than the whole mod. static bool menu_path_in_scope(const std::string& p, const std::string& scope) { return p == scope || p.rfind(scope + ".", 0) == 0; @@ -2456,61 +2286,53 @@ namespace big::mod_settings #pragma region Panel builder - // Level 2: the leaf settings and nested groups inside config section `section` of mod `stem`. struct panel_item { bool is_group = false; - std::string key; // leaf key, or the group's last path segment - toml_v2::config_file::config_entry_base* entry = nullptr; // leaf only - std::string child_section; // group only (full menu path, e.g. "config.x.y") - std::string config_section; // the entry's REAL config section (for virtual I/O - group: its parent config section) - bool is_author_group = false; // group only: declared in configDesc `groups` (not a config section) - localized_text author_name; // author-group display name (is_author_group only) - localized_text author_description; // author-group description (is_author_group only) - localized_text author_disabled_description; // shown instead of the description while disabled - bool author_disabled = false; // author-group `disabled` (already resolved if dynamic) - editable_context group_context = editable_context::any; // group only: when the page may be entered - bool has_order = false; - double order = 0.0; - std::string sort_name; // resolved display name, the alphabetical fallback sort key - bool is_enabled = false; // the mod's master "enabled" toggle (root section only) - bool is_action = false; // a config.lua action button (runs a Lua callback, no config value) - action_info action; // valid when is_action - bool is_virtual = false; // a config.lua virtual row (Lua get/text/set, no config value) - bool virtual_interactive = false; // the virtual row has a `set` (an editable get/set widget) + std::string key; + toml_v2::config_file::config_entry_base* entry = nullptr; + std::string child_section; + std::string config_section; // real config section for virtual I/O. + bool is_author_group = false; + localized_text author_name; + localized_text author_description; + localized_text author_disabled_description; + bool author_disabled = false; // already resolved if dynamic. + editable_context group_context = editable_context::any; + bool has_order = false; + double order = 0.0; + std::string sort_name; + bool is_enabled = false; + bool is_action = false; + action_info action; + bool is_virtual = false; + bool virtual_interactive = false; // has a Lua set(). }; - // One page of the Mods tab before any native widget exists. struct panel_contents { std::vector items; - toml_v2::config_file::config_entry_base* enabled_entry = nullptr; // the mod's master "enabled" toggle - toml_v2::config_file* view_cfg = nullptr; // this mod's config file (for child lookups) + toml_v2::config_file::config_entry_base* enabled_entry = nullptr; + toml_v2::config_file* view_cfg = nullptr; bool mod_enabled = true; }; - // Collects every row belonging on page `section` of mod `stem` - settings, child groups, action buttons and virtual - // rows - and sorts them into display order. static panel_contents collect_panel_items(const std::string& stem, const std::string& section) { panel_contents out; std::vector& items = out.items; - std::map groups; // child menu path -> group item - toml_v2::config_file*& view_cfg = out.view_cfg; // this mod's config file (for child lookups) + std::map groups; + toml_v2::config_file*& view_cfg = out.view_cfg; const std::string section_prefix = section + "."; - // The categories a per-entry `group` can target that do not exist as config sections. const std::vector author_groups = mod_menu_groups(stem); - // Delegates to the shared resolver so the panel and Reset agree on placement. auto resolve_menu_path = [&](const std::string& csection, const std::vector& group) -> std::string { return resolve_entry_menu_path(stem, author_groups, view_cfg, csection, group); }; - // Where an entry sits relative to the current view: 0 = not on this page, 1 = a direct row here, 2 = inside a - // child group (its full menu path returned in child_out). auto placement = [&](const std::string& csection, const std::vector& group, std::string& child_out) -> int { const std::string m = resolve_menu_path(csection, group); @@ -2527,7 +2349,6 @@ namespace big::mod_settings return 0; }; - // Creates the child group row at `child_path`, using author group metadata when available. auto ensure_group = [&](const std::string& child_path) { if (groups.contains(child_path)) @@ -2537,12 +2358,12 @@ namespace big::mod_settings panel_item g; g.is_group = true; g.child_section = child_path; - g.key = child_path.substr(child_path.rfind('.') + 1); // the child's last path segment + g.key = child_path.substr(child_path.rfind('.') + 1); if (const menu_group* ag = find_author_group(author_groups, child_path)) { - g.is_author_group = true; - g.author_name = ag->name; - g.author_description = ag->description; + g.is_author_group = true; + g.author_name = ag->name; + g.author_description = ag->description; g.author_disabled_description = ag->disabled_description; g.author_disabled = ag->disabled; g.group_context = ag->context; @@ -2552,7 +2373,7 @@ namespace big::mod_settings g.order = ag->order; } - // A dynamic field is skipped at load, so re-evaluate the whole declaration against the current state. + // Dynamic fields are skipped at load, so re-evaluate the declaration now. if (ag->has_dynamic) { g_view_has_dynamic = true; @@ -2571,8 +2392,7 @@ namespace big::mod_settings { g.sort_name = resolve_localized(meta->name); - // Config-derived group metadata comes from configDesc.
., whose desc table doubles as - // its children's descriptions, so defer to a real config child of the same name. + // A configDesc child table can also describe its own config child of the same name. if (meta->has_order && !config_child_exists(view_cfg, child_path, "order")) { g.has_order = true; @@ -2609,8 +2429,7 @@ namespace big::mod_settings out.enabled_entry = entry.get(); } - // Undescribed keys are a mod's internal bookkeeping, so they stay off the page. The master "enabled" - // toggle is the exception, always shown so the mod stays toggleable even if undescribed. + // Undescribed keys stay hidden, except the master "enabled" toggle. const bool is_enabled_toggle = key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key); if (!is_enabled_toggle && !setting_is_described(stem, key.m_section, key.m_key) && !entry_has_description(entry.get())) @@ -2618,7 +2437,6 @@ namespace big::mod_settings continue; } - // `group` is static, so the cheap stored metadata is enough to place the entry. const auto static_meta = get_setting_metadata(stem, key.m_section, key.m_key); const std::vector grp = static_meta ? static_meta->group : std::vector{}; std::string child_path; @@ -2649,8 +2467,7 @@ namespace big::mod_settings items.push_back(std::move(kv.second)); } - // Collected across ALL config sections (empty section = all) and bucketed by menu path, so an action moved with - // `group` lands on its target page like any setting. + // Actions are bucketed by menu path like settings. for (auto& a : get_actions(stem, "")) { std::string child_path; @@ -2679,7 +2496,6 @@ namespace big::mod_settings items.push_back(std::move(it)); } - // Interleaved with settings by `order` and source rank. for (const auto& vr : get_virtual_rows(stem, "")) { std::string child_path; @@ -2720,22 +2536,22 @@ namespace big::mod_settings } } - // Row order: pinned "enabled", authored `order`, then displayed name in the active language. + // Row order: pinned "enabled", authored `order`, then localized display name. std::stable_sort(items.begin(), items.end(), [](const panel_item& a, const panel_item& b) { if (a.is_enabled != b.is_enabled) { - return a.is_enabled; // enabled toggle first + return a.is_enabled; } if (a.is_enabled) { - return false; // only one enabled entry exists + return false; } if (a.has_order != b.has_order) { - return a.has_order; // ordered rows before unordered ones + return a.has_order; } if (a.has_order && a.order != b.order) { @@ -2757,7 +2573,6 @@ namespace big::mod_settings const bool is_enabled_row = it.is_enabled; const bool disabled = !is_enabled_row && !mod_enabled; - // An action button runs a Lua callback and edits no config value. if (it.is_action) { if (it.action.has_dynamic) @@ -2765,15 +2580,13 @@ namespace big::mod_settings g_view_has_dynamic = true; } const bool ctx_blocked = is_context_restricted(it.action.context); - const bool mod_off = disabled; // the whole mod is disabled + const bool mod_off = disabled; const bool act_disabled = mod_off || it.action.disabled || ctx_blocked; const std::string name = resolve_localized(it.action.name); const std::string label = escape_markup(name.empty() ? key_to_display(it.key) : name); - // Author-disabled and context-blocked actions stay hoverable to show their notes. if (auto* row = make_button_row(screen, label.c_str(), act_disabled, /*block_input*/ mod_off)) { - // A mod-off action button is fully inert, while soft-disabled actions remain highlightable for their notes. if (mod_off) { row->m_can_be_focused = false; @@ -2781,7 +2594,7 @@ namespace big::mod_settings } else { - // Clear hover and selection overlays so soft-disabled action buttons do not flash clickable. + // Clear overlays so soft-disabled buttons do not flash clickable. *reinterpret_cast(reinterpret_cast(row) + sgg::gui_component_button_under_mouse_texture_offset) = 0; if (g_set_selected_texture) { @@ -2790,9 +2603,8 @@ namespace big::mod_settings } PanelRow pr{row, RowKind::action, stem, it.key}; pr.disabled = act_disabled; - pr.target_section = it.action.section; // the section the action's callback lives in. + pr.target_section = it.action.section; - // Context-blocked rows show the scenario note first. Author-disabled rows show disabledDescription. if (ctx_blocked) { pr.description = @@ -2812,25 +2624,22 @@ namespace big::mod_settings continue; } - // A virtual row's value comes from Lua callbacks, not a config entry. if (it.is_virtual) { - // A `group` override can move a virtual row onto a page whose path differs from its config section, so - // all its Lua I/O (metadata/display/get) uses the row's real config section, not the view path. + // `group` can move a virtual row, so Lua I/O uses its real config section. const std::string& vsection = it.config_section; const auto vmeta = resolved_metadata(stem, vsection, it.key); const std::string vname = vmeta ? resolve_localized(vmeta->name) : std::string{}; const std::string vlabel = escape_markup(!vname.empty() ? vname : key_to_display(it.key)); const std::string vdesc = vmeta ? resolve_localized(vmeta->description) : std::string{}; - // Read-only virtual rows stay mouse-resolvable for their descriptions. const auto build_readonly = [&](const std::string& value_text) { if (auto* row = make_text_row(screen, vlabel.c_str(), /*disabled*/ false, /*block_input*/ false, /*no_hover_highlight*/ true)) { PanelRow pr{row, RowKind::info, stem, it.key}; pr.disabled = true; - pr.config_section = vsection; + pr.config_section = vsection; // real config section. pr.value_component = make_value_display(screen, escape_markup(value_text).c_str(), /*disabled*/ false); pr.description = vdesc; g_rows.push_back(std::move(pr)); @@ -2843,12 +2652,11 @@ namespace big::mod_settings continue; } - // Interactive row. If get() returns nil, `type` can force a widget. Seed it from `default` or a - // fallback so it still builds. + // If get() returns nil, `type` can force a widget seeded from `default` or a fallback. virtual_value vv = get_virtual_value(stem, vsection, it.key); if (vv.type == virtual_value::kind::none && vmeta && vmeta->type != widget_type::inferred) { - const std::string& dflt = vmeta->default_value; // empty when no default declared + const std::string& dflt = vmeta->default_value; switch (vmeta->type) { case widget_type::boolean: @@ -2895,7 +2703,6 @@ namespace big::mod_settings default: break; } - // Author-disabled or context-blocked interactive rows render read-only with a note. const bool author_disabled = vmeta && vmeta->disabled; const editable_context ctx = vmeta ? vmeta->context : editable_context::any; const bool context_blocked = is_context_restricted(ctx); @@ -2946,7 +2753,7 @@ namespace big::mod_settings { PanelRow pr{ro_row, RowKind::setting, stem, it.key}; pr.disabled = true; - pr.config_section = vsection; + pr.config_section = vsection; // real config section. pr.value_component = make_value_display(screen, escape_markup(vtext).c_str(), /*disabled*/ true); pr.description = context_blocked ? @@ -2990,7 +2797,7 @@ namespace big::mod_settings } else { - // Interactive free-text virtual rows are not supported yet, so show the current value read-only. + // Interactive free-text virtual rows are not supported yet. build_readonly(truncate_value(vv_serialized)); continue; } @@ -3000,9 +2807,9 @@ namespace big::mod_settings PanelRow pr{row, RowKind::setting, stem, it.key}; pr.disabled = disabled; pr.is_virtual_input = true; - pr.config_section = vsection; // real config section for runtime get/set (may differ from view path) - pr.value_component = value; - pr.description = vdesc; + pr.config_section = vsection; // real config section. + pr.value_component = value; + pr.description = vdesc; if (is_enum) { pr.is_enum = true; @@ -3022,15 +2829,13 @@ namespace big::mod_settings else if (is_bool) { pr.is_toggle = true; - pr.toggle_value = vv.as_bool; // last-drawn state, for the flip fallback when get() is nil + pr.toggle_value = vv.as_bool; // fallback when get() is nil. } g_rows.push_back(pr); } continue; } - // A nested group drills into its child menu path when activated. A config-derived group takes its name and - // description from its configDesc entry. Author groups carry their own descriptions, captured during collection. if (it.is_group) { std::string glabel; @@ -3049,8 +2854,7 @@ namespace big::mod_settings { auto gmeta = resolved_metadata(stem, section, it.key); - // A group's desc table doubles as its children's descriptions. If displayName/description/hidden or - // disabled is one of the group's own config children, defer to that child. + // A group's desc table can also describe config children of the same name. if (gmeta && view_cfg) { if (config_child_exists(view_cfg, it.child_section, "displayName")) @@ -3082,20 +2886,17 @@ namespace big::mod_settings group_disabled = gmeta && gmeta->disabled; } - // A category the current context does not allow is greyed and cannot be entered, which restricts every - // row inside it without them having to repeat the restriction. + // Context-blocked categories are greyed and cannot be entered. const bool ctx_blocked = is_context_restricted(it.group_context); - // Greyed like any other row: author-disabled or context-blocked keeps it hoverable so the note can be - // read, while the whole mod being disabled makes it fully inert. + // Soft-disabled groups stay hoverable for their notes, while mod-off groups are inert. const bool greyed = disabled || group_disabled || ctx_blocked; if (auto* row = make_text_row(screen, glabel.c_str(), greyed, /*block_input*/ disabled)) { PanelRow pr{row, RowKind::group, stem, {}}; - pr.disabled = greyed; // blocks the drill-in in the click handler + pr.disabled = greyed; pr.target_section = it.child_section; - // Context-blocked rows show the scenario note first. Author-disabled rows show disabledDescription. if (ctx_blocked) { pr.description = note_then_description(context_note(it.group_context), gdescription); @@ -3118,19 +2919,16 @@ namespace big::mod_settings continue; } - // Author-`disabled` keeps the row visible but read-only and greyed. Distinct from the whole-mod-off greying, - // which keeps the native widgets. + // Author-disabled rows are visible but read-only and greyed. const bool author_disabled = meta && meta->disabled; const std::string mname = meta ? resolve_localized(meta->name) : std::string{}; const std::string label = escape_markup(!mname.empty() ? mname : key_to_display(key)); - // Enums use num-boxes, bounded numbers use sliders, and everything else uses freetext. const bool is_number = entry->type() == typeid(double); const bool is_enum = meta && !meta->values.empty(); const bool is_stepper = !is_enum && is_number && meta && meta->has_min && meta->has_max; const double step = (meta && meta->has_step) ? meta->step : 1.0; - // Enum option lists are resolved once so the widget and PanelRow share them. std::vector enum_values; std::vector enum_labels; int enum_index = 0; @@ -3138,7 +2936,6 @@ namespace big::mod_settings { enum_values = meta->values; - // Labels parallel the values when the author supplied a full set, otherwise values label themselves. if (meta->labels.size() == enum_values.size()) { for (const auto& lbl : meta->labels) @@ -3164,21 +2961,20 @@ namespace big::mod_settings GUIComponent* row = nullptr; GUIComponent* value = nullptr; bool built_slider = false; - bool built_stepper = false; // num-box fallback used when the slider could not be built + bool built_stepper = false; bool built_enum = false; bool built_toggle = false; - // Context-blocked and author-disabled settings still take focus so the description can explain why. const editable_context ctx = effective_editable_context(meta, is_enabled_row); const bool context_blocked = is_context_restricted(ctx); if (!disabled && (context_blocked || author_disabled)) { - // Greyed widgets stay focusable for their descriptions. Edits are blocked by pr.disabled. + // Greyed widgets stay focusable for their descriptions. GUIComponent* ro_row = nullptr; GUIComponent* ro_value = nullptr; bool ro_is_toggle = false; - bool ro_is_enum = false; // real enum cycler (carries values/labels) - bool ro_is_numbox = false; // numeric num-box (stepper fallback when the slider cannot be built) + bool ro_is_enum = false; + bool ro_is_numbox = false; bool ro_is_slider = false; if (entry->type() == typeid(bool)) { @@ -3213,12 +3009,12 @@ namespace big::mod_settings if (ro_row) { PanelRow pr{ro_row, RowKind::setting, stem, key, entry}; - pr.disabled = true; // blocks every edit path (click/slider/num-box) via the row handlers + pr.disabled = true; // blocks every edit path. pr.is_enabled_toggle = is_enabled_row; if (ro_is_slider || ro_is_numbox) { - pr.is_slider = ro_is_slider; // slider drag bar, or ... - pr.is_stepper = ro_is_numbox; // ... num-box stepper fallback (shares the revert path) + pr.is_slider = ro_is_slider; + pr.is_stepper = ro_is_numbox; pr.stepper_min = meta->min; pr.stepper_max = meta->max; pr.stepper_step = step; @@ -3240,7 +3036,6 @@ namespace big::mod_settings pr.value_component = ro_value; } - // Context-blocked rows show the scenario note first. Author-disabled rows show disabledDescription. if (context_blocked) { pr.description = note_then_description(context_note(ctx), meta ? resolve_localized(meta->description) : std::string{}); @@ -3267,7 +3062,6 @@ namespace big::mod_settings } else if (is_stepper) { - // Bounded numbers use sliders, falling back to a num-box if the slider cannot be built. row = make_slider_row(screen, label.c_str(), meta->min, meta->max, step, entry->get_value_base(), meta->show_as_percentage, meta->is_percentage, disabled); if (row) { @@ -3296,8 +3090,6 @@ namespace big::mod_settings pr.is_enabled_toggle = is_enabled_row; pr.value_component = value; - // Prefer the author's metadata description (resolved to the current language), else fall back to the - // .cfg comment text. const std::string mdesc = meta ? resolve_localized(meta->description) : std::string{}; pr.description = !mdesc.empty() ? mdesc : entry->m_description.m_description; @@ -3336,9 +3128,7 @@ namespace big::mod_settings #pragma region Panel sync, focus, and navigation - // Matches the native category-switch transition: the incoming page fades in, with no fade-out crossover. Native - // UpdateScrollState sets each on-page row's mFadeTarget to 1 and off-page rows to 0, and GUIComponent::Update eases - // toward it - so on-page rows are left entirely to the native ease. Rows are in m_options/g_rows order. + // Match native category switching: on-page rows fade in, off-page rows are hidden immediately. static void sync_scroll_fade(MiscSettingsScreen* screen) { const std::size_t first = screen->m_page_start_index; @@ -3354,8 +3144,7 @@ namespace big::mod_settings } } - // Value displays are not in mOptions, so the engine's scroll pass does not lay them out. Mirror each value - // component onto its key row's current position and fade so the right column tracks scrolling and fade-in/out. + // Value displays are outside mOptions, so mirror the key row's position and fade. static void sync_value_columns() { for (const auto& row : g_rows) @@ -3374,14 +3163,12 @@ namespace big::mod_settings } } - // The component the user is currently on: mouse-over first, otherwise keyboard/controller selection. static GUIComponent* active_row_component(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); return menu->m_mouse_over_component ? menu->m_mouse_over_component : menu->m_selected_component; } - // Finds the PanelRow whose left-column component is `comp`. Valid until the next panel rebuild. static PanelRow* find_row(GUIComponent* comp) { if (!comp) @@ -3398,7 +3185,6 @@ namespace big::mod_settings return nullptr; } - // Builds a stable identity for a row so it can be re-found after a rebuild recreates the components. static std::string row_config_section_of(const PanelRow& r) { if (r.entry) @@ -3417,8 +3203,7 @@ namespace big::mod_settings return RowIdentity{true, r.kind, r.stem, r.target_section, r.setting_key, row_config_section_of(r)}; } - // The freshly built row matching a captured identity, or null if it is gone or is no longer selectable. Used to put - // the hover and selection back on the equivalent new row after an instant rebuild. + // Re-finds a selectable row after an instant rebuild. static GUIComponent* find_row_by_identity(const RowIdentity& id) { if (!id.valid) @@ -3436,9 +3221,7 @@ namespace big::mod_settings return nullptr; } - // True while the user is still actively adjusting one of our rows: the entered component (keyboard or controller - // adjusting a slider/enum), or a mouse drag (a mouse button held over one of our rows). If the mouse-down probe is - // unavailable, any hover holds instead, so a drag is never interrupted. + // True while the user is adjusting one of our rows. If the mouse-down probe is absent, hover holds. static bool interacting_with_row(MiscSettingsScreen* screen, void* input) { if (screen->m_component_focused && find_row(screen->m_component_focused)) @@ -3453,10 +3236,8 @@ namespace big::mod_settings return g_mouse_button_down ? g_mouse_button_down(input) : true; } - // The component whose description was last written to the description box. static GUIComponent* g_last_description_component = nullptr; - // Shows the highlighted row's author description in the native description box. static void sync_description_box(MiscSettingsScreen* screen) { if (!g_show_text || !screen->m_description_box) @@ -3478,7 +3259,6 @@ namespace big::mod_settings { g_last_description_component = active; - // Escape markup and convert embedded newlines to the text box's hard-break escape. std::string shown; if (show) { @@ -3490,27 +3270,22 @@ namespace big::mod_settings } g_show_text(box, shown.c_str()); - // ShowText only marks the lines dirty. The layout (and text height, which the box's justification uses to - // place the text) is otherwise recomputed lazily at draw time, so the first visible frame would render at a - // stale position and visibly jump. Force the line rebuild now so the first shown frame is already laid out. + // ShowText only marks lines dirty, so force layout now to avoid a first-frame jump. if (show && g_get_lines) { g_get_lines(box); } } - // Re-apply the fade every frame because native Update runs before this and re-hides the box. + // Native Update runs before this and re-hides the box. box->m_fade_opacity = show ? 1.0f : 0.0f; box->m_fade_target = show ? 1.0f : 0.0f; } - // Last label we wrote to each bottom-prompt button, so SetDisplayName only runs when the label changes. static std::string g_prompt_confirm_label; static std::string g_prompt_cancel_label; - // Sets a bottom-prompt button's label (GUIComponentButton::SetDisplayName) only when it changes from what we last - // set. The key glyph is driven by the button's bound control, not the label, so it stays correct (Enter for - // Confirm, Esc for Cancel) regardless of the text. + // The key glyph comes from the button's bound control, not the label. static void set_prompt_label(GUIComponent* button, std::string& cache, const char* text) { if (!button || !g_set_label || cache == text) @@ -3521,8 +3296,7 @@ namespace big::mod_settings g_set_label(button, text); } - // Retunes the options screen's bottom button prompts for the Mods tab per context, and hides the native Reset prompt - // where it must not apply. Off the Mods tab it only clears our caches and leaves the native prompts untouched. + // Retunes bottom prompts for the Mods tab and clears caches off-tab. static void sync_prompts(MiscSettingsScreen* screen, bool on_mods_tab) { if (!on_mods_tab) @@ -3534,7 +3308,6 @@ namespace big::mod_settings auto* menu = reinterpret_cast(screen); - // Labels are upper-case to match the game: "CANCEL" while editing, "BACK" in settings, "EXIT" at the mod list. const char* cancel = g_editing ? "{CN} CANCEL" : (g_view == View::mod_settings ? "{CN} BACK" : "{CN} EXIT"); set_prompt_label(menu->m_cancel_button, g_prompt_cancel_label, cancel); @@ -3566,7 +3339,7 @@ namespace big::mod_settings } else if (row->is_slider) { - confirm = "{SL} SET"; // matches the base-game volume sliders' prompt + confirm = "{SL} SET"; } else if (row->is_stepper) { @@ -3574,7 +3347,7 @@ namespace big::mod_settings } else { - confirm = "{SL} EDIT"; // freetext value + confirm = "{SL} EDIT"; } break; case RowKind::action: confirm = "{SL} SELECT"; break; @@ -3582,7 +3355,7 @@ namespace big::mod_settings } } - // Drive Confirm prompt visibility ourselves because native OnOptionMouseOver never fires for our custom rows. + // Native OnOptionMouseOver never fires for our custom rows. if (menu->m_confirm_button) { if (confirm.empty()) @@ -3599,9 +3372,7 @@ namespace big::mod_settings } } - // Reset prompt: shown only inside a single mod's settings (resets that mod) and not while editing. It is hidden - // in the mod list/overview so users cannot reset every mod's config by accident (the RestoreDefaults hook also - // swallows the shortcut there). + // Hide Reset outside a single mod's settings and while editing. if (screen->m_defaults_button) { const bool show_reset = (g_view == View::mod_settings) && !g_editing; @@ -3609,18 +3380,15 @@ namespace big::mod_settings } } - // Focuses the first selectable row so the controller/keyboard cursor lands on it, as a native category does. The - // engine's DoShowCategory only does this when the option list is already populated, and our rows are appended - // afterwards - so without this the screen stays in tab-navigation mode and the stick never reaches the rows until - // the tab is selected a second time. + // DoShowCategory focuses rows only when the option list is already populated. static void focus_row(MiscSettingsScreen* screen, GUIComponent* component) { if (!g_teleport_cursor || (g_use_mouse && *g_use_mouse) || !component) { return; } - g_teleport_cursor(screen, component); // drop the cursor on the row, next Update focuses it - screen->m_category_focused = false; // hand navigation from the tab bar to the option rows + g_teleport_cursor(screen, component); // next Update focuses it. + screen->m_category_focused = false; // hand nav from tabs to rows. } static void focus_first_row(MiscSettingsScreen* screen) @@ -3640,9 +3408,7 @@ namespace big::mod_settings } } - // After a native page scroll the engine selects the new page's edge row directly, without consulting - // mFreeFormSelectable, so redirect it to the first selectable row instead. Falls back to the native edge selection - // when every row on the page is disabled. Mouse mode is untouched, since the pointer drives hover itself. + // Native page scroll selects the edge row directly, ignoring mFreeFormSelectable. static void redirect_page_landing(MiscSettingsScreen* screen, bool going_down) { if (!g_set_mouse_over || !g_teleport_cursor || (g_use_mouse && *g_use_mouse) || g_rows.empty()) @@ -3654,7 +3420,7 @@ namespace big::mod_settings { return; } - const std::size_t page_end = std::min(page_start + rows_per_page, g_rows.size()); // exclusive + const std::size_t page_end = std::min(page_start + rows_per_page, g_rows.size()); const auto eligible = [](const PanelRow& r) { @@ -3685,19 +3451,17 @@ namespace big::mod_settings } } - // No eligible row on this page (all disabled): keep the native edge selection. auto* menu = reinterpret_cast(screen); if (!target || menu->m_mouse_over_component == target) { return; } - g_set_mouse_over(screen, target); // remove the highlight from the edge row and place it on the eligible one - g_teleport_cursor(screen, target); // the free-form cursor follows so the next press moves from here + g_set_mouse_over(screen, target); + g_teleport_cursor(screen, target); screen->m_category_focused = false; } - // The row a pending back-navigation should re-focus. static GUIComponent* restore_target_row(const NavRestore& r) { for (const auto& row : g_rows) @@ -3716,8 +3480,6 @@ namespace big::mod_settings return nullptr; } - // Queues a one-level back navigation inside a mod's settings: a nested group returns to its parent section, and the - // root returns to the mod list. Applied next Update via apply_nav. static void request_back_nav() { const auto dot = g_view_section.rfind('.'); @@ -3736,8 +3498,7 @@ namespace big::mod_settings g_nav_pending = true; } - // True if a remappable control (e.g. Back/Cancel = controller B + keyboard Esc, or Select = controller A + Enter) - // was pressed this frame. Bit 0x4 of the control's state is "was pressed" (edge, not held). + // Bit 0x4 of a control's state is "was pressed". static bool control_pressed(void* input, const void* control) { if (!input || !g_input_get_state || !control) @@ -3747,7 +3508,6 @@ namespace big::mod_settings return (g_input_get_state(input, control) & 0x4u) != 0; } - // Holds the clicked row as moused-over and selected for a few frames after a click-triggered rebuild. static void reassert_keep_active_row(MiscSettingsScreen* screen) { if (!(g_use_mouse && *g_use_mouse)) @@ -3763,14 +3523,12 @@ namespace big::mod_settings menu->m_mouse_over_component = keep; menu->m_selected_component = keep; - // Clear caches so this frame's sync overrides a native clear on the rebuild frame. g_prompt_confirm_label.clear(); g_prompt_cancel_label.clear(); g_last_description_component = nullptr; } - // Calls a no-argument GUIComponent virtual (by byte offset into the vtable) on a component. Used to invoke the - // engine's own OnMouseOff/OnFocusOff so their full revert (text-colour flag plus the fill-texture swap) runs. + // Calls an engine virtual by byte offset so native highlight reverts run fully. static void call_component_vfn(GUIComponent* comp, std::size_t vtable_byte_offset) { char* vtable = *reinterpret_cast(comp); @@ -3778,7 +3536,6 @@ namespace big::mod_settings reinterpret_cast(fn)(comp); } - // Moves a row through the engine's own SetLocation so child components stay in step. static void set_component_location(GUIComponent* comp, float x, float y) { char* vtable = *reinterpret_cast(comp); @@ -3789,10 +3546,7 @@ namespace big::mod_settings fn(comp, (static_cast(yb) << 32) | xb); } - // Reverts a stale highlight left on the wrong slider or num-box row. The two differ in which handler sets the look: a - // slider's is set by OnMouseOver and reverted by OnMouseOff, a num-box's by OnSelected and reverted by OnUnselected - // (its OnMouseOff is an inherited no-op). The num-box look also fires under keyboard/controller nav, so its revert is - // gated to mouse mode to avoid clearing a genuine gamepad selection. Both carry a focus look reverted by OnFocusOff. + // Sliders revert through OnMouseOff, num-boxes through OnUnselected. static void clear_stale_widget_highlight(MiscSettingsScreen* screen) { auto* menu = reinterpret_cast(screen); @@ -3807,7 +3561,6 @@ namespace big::mod_settings if (row.is_slider) { - // Disabled still-selectable sliders are reverted even while hovered so they stay greyed. if (row.disabled || row.component != menu->m_mouse_over_component) { if (auto* label = *reinterpret_cast(s + slider_label_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) @@ -3823,7 +3576,6 @@ namespace big::mod_settings } else if ((row.is_enum || row.is_stepper) && (mouse_mode || row.disabled)) { - // Num-box selected look is reverted through OnUnselected because OnMouseOff is a no-op here. if (row.disabled || row.component != menu->m_mouse_over_component) { if (auto* label = *reinterpret_cast(s + numbox_label_text_offset); label && *reinterpret_cast(label + textbox_use_selected_color_off)) @@ -3835,7 +3587,6 @@ namespace big::mod_settings } } - // Keeps a greyed-but-still-selectable widget row's label and value text greyed. static void keep_disabled_labels_grey() { const auto grey_label = [](char* base, std::size_t tb_offset) @@ -3873,10 +3624,7 @@ namespace big::mod_settings } } - // Makes the spatial nav skip disabled rows so UP/DOWN jumps to the next interactable one, while leaving mouse hover - // alone so a greyed row can still be rested on to read its description. mFreeFormSelectable is the only gate - // SearchInDirection checks before IsSelectable, and one UpdateMouseOver never reads. Reapplied after every build, - // since the row objects are recreated each time. + // mFreeFormSelectable makes spatial nav skip disabled rows without blocking mouse hover. static void apply_row_freeform_selectability() { for (const auto& row : g_rows) @@ -3898,17 +3646,12 @@ namespace big::mod_settings static void build_panel(MiscSettingsScreen* screen, bool instant = false) { - // A rebuild frees the highlighted-row pointer, so refresh the description next frame. g_last_description_component = nullptr; - // Preserve the current scroll offset across an in-place refresh (same view/mod, e.g. after committing a setting - // edit or toggling "enabled") so confirming a setting on a lower page does not jump back to the top. A real - // view change (instant == false) starts at the top. + // In-place refreshes preserve the current scroll offset. const std::uint32_t prev_start = screen->m_page_start_index; - // On an instant (same-view) rebuild the highlighted row is freed and recreated, so remember it to put the - // keyboard/controller cursor back afterwards (mouse uses hover, so this is gated to non-mouse mode). Only - // setting/action rows (those with a key) are tracked. + // Same-view rebuilds recreate rows, so remember the keyboard/controller cursor row. RowKind cursor_kind = RowKind::mod_entry; std::string cursor_key; bool had_cursor = false; @@ -3926,8 +3669,7 @@ namespace big::mod_settings destroy_rows(screen); - // Resolve the blank graphic lazily: the string-intern table is not ready at hook registration time, so "Blank" - // only hashes correctly once the game is running. + // "Blank" only hashes correctly once the string-intern table is ready. if (!g_blank_graphic && g_hash_lookup) { HashGuid res{}; @@ -3935,8 +3677,6 @@ namespace big::mod_settings g_blank_graphic = res.m_id; } - // Recomputed during the build: true if any row in the new view has a dynamic (Lua-function) field, so a bool - // toggle should trigger an in-place rebuild to re-evaluate it live. g_view_has_dynamic = false; g_dynamic_refresh_settle = 0.0f; @@ -3950,16 +3690,13 @@ namespace big::mod_settings build_mod_list(screen); } - // Let the engine position, paginate and drive the scrollbar/arrows for the rows. If a restore is pending from the - // back-nav, restore that view's saved scroll offset so the user lands where they were. + // Let the engine position, paginate and drive the scrollbar and arrows. const bool restoring = !instant && g_has_pending_restore; std::uint32_t start = 0; if (instant || restoring) { - // Restore the exact offset the view had. Only clamp when it now points past the last row (the row count - // shrank, e.g. a row became hidden), and then to the first index of the last page - so a partial final page - // (fewer than rows_per_page rows) keeps its own offset instead of being pulled up into a full page of rows. + // Clamp only when the saved offset now points past the last row. const std::uint32_t row_count = static_cast(g_rows.size()); const std::uint32_t last_page_start = row_count > 0 ? ((row_count - 1) / rows_per_page) * rows_per_page : 0; const std::uint32_t desired = instant ? prev_start : g_pending_restore.scroll_index; @@ -3969,13 +3706,12 @@ namespace big::mod_settings screen->m_options_per_page = rows_per_page; if (g_update_scroll) { - g_update_scroll(screen); // sets each row's mFadeTarget: 1 on-page, 0 off-page + g_update_scroll(screen); // sets each row's mFadeTarget. } if (instant) { - // In-place refresh (e.g. toggling the mod's "enabled" switch, which only changes greying): snap each row - // straight to its final visibility so the panel does not flash a fade. + // Snap in-place refreshes to their final visibility so the panel does not flash. for (const auto& row : g_rows) { if (row.component) @@ -3985,14 +3721,11 @@ namespace big::mod_settings } } - // A view change leaves fresh rows transparent so native GUIComponent::Update fades them in. sync_value_columns(); apply_row_freeform_selectability(); - // On a real view change (tab entry, drilling in, going back), drop the cursor on the first row so it highlights - // immediately like a native category. Skipped on in-place refreshes so committing an edit or toggling "enabled" does - // not yank focus back to the top. + // Real view changes focus the first row, while in-place refreshes keep focus. if (!instant) { GUIComponent* restore_focus = restoring ? restore_target_row(g_pending_restore) : nullptr; @@ -4007,7 +3740,6 @@ namespace big::mod_settings } else if (had_cursor) { - // Put the keyboard/controller cursor back on the equivalent new row after an instant rebuild. for (const auto& row : g_rows) { if (row.component && row.kind == cursor_kind && row.setting_key == cursor_key && !row.disabled @@ -4019,7 +3751,6 @@ namespace big::mod_settings } } - // Arm a short re-assert window after a click-triggered instant rebuild. if (instant && g_keep_active_row.valid && g_use_mouse && *g_use_mouse) { g_keep_active_frames = keep_active_frame_count; @@ -4036,14 +3767,10 @@ namespace big::mod_settings } } - // Applies a queued navigation (mod list <-> a mod's settings) by rebuilding the panel. A rebuild that stays on the - // same view/mod (e.g. after toggling "enabled") is applied instantly to avoid a fade flash. static void apply_nav(MiscSettingsScreen* screen) { - // Same-view rebuilds preserve the current scroll page instead of snapping back to the top. const bool instant = (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); - // Maintain the restore stack. const bool drilling_in = (g_view == View::mod_list && g_pending_view == View::mod_settings) || (g_view == View::mod_settings && g_pending_view == View::mod_settings && g_pending_section.rfind(g_view_section + ".", 0) == 0); @@ -4057,11 +3784,11 @@ namespace big::mod_settings r.scroll_index = screen->m_page_start_index; if (g_view == View::mod_list) { - r.focus_stem = g_pending_stem; // the mod being opened + r.focus_stem = g_pending_stem; } else { - r.focus_section = g_pending_section; // the child section being opened (a group row's target) + r.focus_section = g_pending_section; } g_nav_stack.push_back(std::move(r)); } @@ -4082,7 +3809,7 @@ namespace big::mod_settings #pragma region Reset to defaults - // The serialized default of a config entry, read via write_description and round-tripped through set_serialized_value. + // Reads the serialized default from write_description for set_serialized_value. static std::optional entry_default_serialized(toml_v2::config_file::config_entry_base* entry) { if (!entry) @@ -4101,14 +3828,12 @@ namespace big::mod_settings return text.substr(pos + marker.size()); } - // Restores config entries to their defaults, but only those whose MENU path lies within the current view, so a Reset - // inside a group leaves siblings and parents untouched. At the mod root that is every described entry. Defaults come - // from the config.lua value captured by rom.mod_settings.load when available, else the entry's own stored default. + // Reset affects only entries whose menu path is within the current view. static bool reset_settings_to_defaults() { bool any_changed = false; const std::vector author_groups = mod_menu_groups(g_view_stem); - toml_v2::config_file* mod_cfg = nullptr; // any config file of this mod, for virtual-row path resolution. + toml_v2::config_file* mod_cfg = nullptr; // for virtual-row path resolution. for (auto* cfg : toml_v2::config_file::g_config_files) { if (!cfg || cfg->m_config_file_stem_as_str.empty() || cfg->m_config_file_stem_as_str != g_view_stem) @@ -4128,14 +3853,12 @@ namespace big::mod_settings } auto* e = entry.get(); - // Reset only shown settings plus the master "enabled" toggle, leaving hidden internal state untouched. const bool is_enabled_toggle = def.m_section == root_section && e->type() == typeid(bool) && is_enabled_key(def.m_key); if (!is_enabled_toggle && !setting_is_described(guid, def.m_section, def.m_key) && !entry_has_description(e)) { continue; } - // Skip entries outside the current menu group. const auto static_meta = get_setting_metadata(guid, def.m_section, def.m_key); const std::vector grp = static_meta ? static_meta->group : std::vector{}; const std::string mpath = resolve_entry_menu_path(guid, author_groups, cfg, def.m_section, grp); @@ -4147,7 +3870,7 @@ namespace big::mod_settings auto def_val = get_setting_default(guid, def.m_section, def.m_key); if (!def_val) { - // Chalk mods recover the default from the config entry itself. + // Chalk mods recover defaults from the config entry itself. def_val = entry_default_serialized(e); } if (!def_val || e->get_serialized_value() == *def_val) @@ -4161,8 +3884,7 @@ namespace big::mod_settings } } - // Interactive virtual rows are not config entries, so restore any in scope that declare a `default` through - // their set() callback here (read-only rows and rows without a default are left untouched). + // Interactive virtual rows with a `default` are reset through set(). for (const auto& vr : get_virtual_rows(g_view_stem, "")) { if (!vr.interactive) @@ -4182,9 +3904,6 @@ namespace big::mod_settings return any_changed; } - // Handles a Reset activation on the Mods tab: restores the in-scope settings to their config.lua defaults, then (in a - // mod's settings view, where the changed values are on screen) queues an in-place rebuild so the widgets show the - // restored values. Safe to call from input/click context because the rebuild is deferred to the Update hook. static void perform_reset() { const bool changed = reset_settings_to_defaults(); @@ -4201,28 +3920,24 @@ namespace big::mod_settings #pragma region Native dialogs and dependency checks - // True when the game's current display language uses a CJK font (zh-CN, zh-TW, ja, ko). Those fonts have no glyph for - // the non-breaking space U+00A0 and draw a visible '*' instead, so the restart message uses regular spaces and a - // U+3000 blank for them. + // CJK fonts draw U+00A0 as '*', so use regular spaces and a U+3000 blank. static bool current_language_is_cjk() { const std::string code = current_language_code(); return code.rfind("zh", 0) == 0 || code.rfind("ja", 0) == 0 || code.rfind("ko", 0) == 0; } - // Builds a locale-aware popup body: an intro line, a blank line, one line per list entry, a blank line, then an outro - // line (plus a sacrificial trailing blank). Both blank characters survive ShowText's ASCII-whitespace-line trim. - // Shared by the restart-required and dependency-block dialogs. + // Blank characters must survive ShowText's ASCII-whitespace-line trim. static std::string build_list_message(const std::string& intro, const std::vector& lines, const std::string& outro) { const bool cjk = current_language_is_cjk(); - const std::string blank = cjk ? "\xE3\x80\x80" : "\xC2\xA0"; // U+3000 (CJK) or U+00A0 (other) + const std::string blank = cjk ? "\xE3\x80\x80" : "\xC2\xA0"; // U+3000 or U+00A0. const auto spaced = [cjk](const std::string& s) -> std::string { if (cjk) { - return s; // regular spaces render in the CJK font, the entries fit without non-breaking + return s; } std::string out; out.reserve(s.size() + s.size() / 4); @@ -4246,7 +3961,6 @@ namespace big::mod_settings return msg; } - // Builds the restart-popup body text from the changes collected this session. static std::string build_restart_message() { std::vector lines; @@ -4258,7 +3972,7 @@ namespace big::mod_settings return build_list_message("A restart is required because you changed these settings:", lines, "The game will now close. Please restart it to apply the changes."); } - // Builds an empty EASTL SSO string in `buf`. The real message is applied afterwards via ShowText. + // Builds an EASTL SSO string. static void make_eastl_sso(char* buf, const char* text) { std::size_t n = std::strlen(text); @@ -4271,10 +3985,7 @@ namespace big::mod_settings buf[23] = static_cast(23 - n); } - // Persists the game's native Options settings (language, audio volumes, resolution/window/graphics, and all - // gameplay/interface/accessibility toggles) to disk. The engine normally does this only when the options screen - // finishes closing (MiscSettingsScreen::OnExit -> ProfileManager::SaveProfile), which never runs when we force a - // restart. Uses SaveProfile's synchronous path (async=false, no save spinner) so the files are written before we exit. + // Forced restart skips OnExit, so SaveProfile must flush native Options settings first. static void flush_native_settings() { if (g_save_profile && g_active_profile) @@ -4283,21 +3994,17 @@ namespace big::mod_settings } } - // Shows the native single-button message box, modal over the options screen. When confirm_closes_game is set the - // confirm button is captured so the OnClicked hook closes the game on press (a forced restart, which must not be - // cancellable). Otherwise the button keeps its native dismiss behaviour. + // Captures the confirm button only for the forced-restart dialog. static bool show_message_dialog(void* screen_manager, const char* title, const std::string& message, bool confirm_closes_game) { if (screen_manager && g_message_dialog_ctor && g_add_screen) { - // The ScreenManager takes ownership and frees this with ucrtbase's _aligned_free, so it must come from the - // game's heap (see game_alloc). + // ScreenManager frees dialogs with the game's CRT heap. void* dialog = game_alloc(message_dialog_size); if (dialog) { std::memset(dialog, 0, message_dialog_size); - // Pass an empty message so the real multi-line text can be applied below via ShowText. char empty_message[24]; make_eastl_sso(empty_message, ""); g_message_dialog_ctor(dialog, screen_manager, empty_message); @@ -4316,27 +4023,22 @@ namespace big::mod_settings } if (auto* message_box = *reinterpret_cast(bytes + dialog_message_offset)) { - // Shrink the loaded font handle before ShowText lays out the lines. char* handle = reinterpret_cast(message_box) + textbox_font_handle_offset; *reinterpret_cast(handle + font_handle_size_ratio_offset) *= restart_message_font_scale; *reinterpret_cast(handle + font_handle_eng_size_ratio_offset) *= restart_message_font_scale; - // Escape markup so path values render verbatim. const std::string shown = escape_markup(message); g_show_text(message_box, shown.c_str()); } } - // Capture the confirm button only when it should close the game. Otherwise the native confirm behaviour - // dismisses the dialog. Remember the dialog so the OnClicked hook can confirm the clicked button still - // belongs to it before terminating. + // Remember the dialog so OnClicked can verify ownership before terminating. if (confirm_closes_game) { g_restart_confirm_button = *reinterpret_cast(bytes + dialog_confirm_button_offset); g_restart_dialog = dialog; } - // Add at the end of the screen list so it draws on top. char empty_name[24]; make_eastl_sso(empty_name, ""); g_add_screen(screen_manager, dialog, true, empty_name); @@ -4347,21 +4049,16 @@ namespace big::mod_settings return false; } - // The restart-required prompt. Its only button closes the game. static bool show_restart_dialog(void* screen_manager, const std::string& message) { return show_message_dialog(screen_manager, "Restart Required", message, /*confirm_closes_game*/ true); } - // The dependency-block prompt is informational, so its button just dismisses the dialog. static bool show_dependency_dialog(void* screen_manager, const std::string& message) { return show_message_dialog(screen_manager, "Cannot Disable Mod", message, /*confirm_closes_game*/ false); } - // True if the mod with config-file stem/guid `guid` is currently enabled: the value of its master "enabled" - // root-section toggle, or true when it has no such toggle (a mod with no enable switch is always active). Reads the - // live config value, so it reflects any change made this menu session. static bool mod_is_enabled(const std::string& guid) { for (auto* cfg : toml_v2::config_file::g_config_files) @@ -4381,7 +4078,6 @@ namespace big::mod_settings return true; } - // Display names of enabled loaded mods that declare `stem` as a Thunderstore dependency. static std::vector active_dependents_of(const std::string& stem) { std::vector result; @@ -4411,9 +4107,6 @@ namespace big::mod_settings return result; } - // Body text for the dependency-block popup: lists the enabled mods depending on the one the player tried to disable, - // and tells them how to proceed. The blocked mod is identified by the dialog title and the toggle the player just - // clicked, so it is not repeated here. static std::string build_dependency_message(const std::vector& dependents) { return build_list_message("These enabled mods depend on this one:", dependents, "Disable them first to disable this mod."); @@ -4425,9 +4118,7 @@ namespace big::mod_settings static void* hook_MiscSettingsScreen_ctor(void* self, void* screen_manager, void* opened_from, void* profile_name) { - // Reset state BEFORE running the original ctor: the original ctor immediately shows the last-viewed category, - // and if that is the Mods tab it builds our panel via DoShowCategory. Clearing g_rows after the original would - // wipe those fresh rows. + // The original ctor may build our panel via DoShowCategory. g_rows.clear(); g_view = View::mod_list; g_view_stem.clear(); @@ -4447,9 +4138,7 @@ namespace big::mod_settings g_prompt_cancel_label.clear(); exit_edit_mode(); - // Record whether the screen was opened during gameplay (a save loaded) or from the main menu, so context-restricted - // rows can be greyed. Must be set before the original ctor runs, which shows the last-viewed category and may build - // our panel via DoShowCategory. + // Must be set before the original ctor may build our panel. g_opened_in_game = opener_indicates_in_game(opened_from); g_in_hub = game_is_in_hub(); g_options_screen_open = true; @@ -4471,10 +4160,7 @@ namespace big::mod_settings auto* screen = static_cast(self); const bool is_mods_tab = category_button && category_button == reinterpret_cast(screen->m_editor_options_button); - // Leaving the Mods tab for another category: tear our rows down FIRST, before the native category switch runs. They - // would then linger in mComponents on the other category - re-localized by a language change and walked by the native - // layout - which can corrupt unrelated widgets (e.g. a category button's label). Doing our own teardown here keeps - // mComponents clean for the native code; re-entering the tab rebuilds them. + // Tear down rows before the native category switch so mComponents stays clean. if (!is_mods_tab && !g_rows.empty()) { destroy_rows(screen); @@ -4487,13 +4173,11 @@ namespace big::mod_settings if (is_mods_tab) { - // Entering the tab always starts at the mod list. Drill-down happens in-place via the Update hook, not by - // re-entering the category. g_view = View::mod_list; g_view_stem.clear(); g_view_section.clear(); g_nav_pending = false; - g_nav_stack.clear(); // a fresh tab entry starts at the top of the mod list. + g_nav_stack.clear(); g_has_pending_restore = false; exit_edit_mode(); build_panel(screen); @@ -4502,7 +4186,6 @@ namespace big::mod_settings return result; } - // Value-change hook for our native num-box rows, filtered because it also fires for native settings num-boxes. static void hook_GUIComponentNumBox_SetNumberValue(void* self, float value, bool notify) { big::g_hooking->get_original()(self, value, notify); @@ -4518,7 +4201,6 @@ namespace big::mod_settings return; } - // Enum cyclers persist the matching serialized value and repaint the option label. if (row->is_enum) { int idx = static_cast(*reinterpret_cast(reinterpret_cast(self) + numbox_value_offset)); @@ -4540,10 +4222,7 @@ namespace big::mod_settings commit_row_number(row, new_value); } - // Persists a user drag or adjust on our slider rows. Fires for the native audio sliders too, hence the find_row - // filter. mFraction is deliberately left continuous rather than snapped: the native adjust accumulates a small - // per-frame delta into it, so re-snapping each frame would discard any delta below half a step and a partial stick - // deflection would never move the slider. + // Leave mFraction continuous so native small deltas can accumulate before snapping on commit. static void hook_GUIComponentSlider_SetFraction(void* self, float fraction, bool notify) { big::g_hooking->get_original()(self, fraction, notify); @@ -4585,9 +4264,7 @@ namespace big::mod_settings format_setting_display(v, row->show_as_percentage, row->is_percentage, step_v).c_str()); } - // Moves a slider row one grid step (dir -1 or +1) from its current snapped value, clamped to [min, max], and writes - // the exact grid fraction through SetFraction with notify so the SetFraction hook stores the value and repaints the - // value text. The stored value is already on the grid, so rounding the current index is just a safety net. + // Writes through SetFraction with notify so the SetFraction hook stores and repaints. static void step_slider_row(void* slider, PanelRow* row, int dir) { if (!g_slider_set_fraction || (!row->entry && !row->is_virtual_input)) @@ -4617,16 +4294,12 @@ namespace big::mod_settings g_slider_set_fraction(slider, static_cast((v - min_v) / range), true); } - // Auto-repeat state for the slider row currently taking input. GUIComponentSlider carries no repeat fields of its - // own and only one row can be focused, so a single slot keyed by the component is enough. + // GUIComponentSlider carries no repeat fields of its own. static void* g_slider_repeat_component = nullptr; static float g_slider_repeat_timer = 0.0f; static int g_slider_repeat_dir = 0; - // Discrete keyboard/controller stepping for our slider rows, and a disabled-row guard. The native HandleInput - // slides mFraction continuously behind a dead-zone, so a small tap can land back on the same snapped value. Under - // keyboard/controller we bypass it and move whole steps, gated on the slider's own mFocused so only the entered - // slider reacts. A held direction repeats, since one press per step makes a wide range unusable. + // Native HandleInput slides mFraction continuously, so keyboard/controller input steps manually. static bool hook_GUIComponentSlider_HandleInput(void* self, void* input, float dt) { PanelRow* row = self ? find_row(reinterpret_cast(self)) : nullptr; @@ -4634,19 +4307,18 @@ namespace big::mod_settings { if (row->disabled) { - return false; // greyed slider: swallow so the native mouse-drag/slide never adjusts it + return false; // swallow native drag. } if ((row->entry || row->is_virtual_input) && !(g_use_mouse && *g_use_mouse) && *reinterpret_cast(reinterpret_cast(self) + slider_focused_offset)) { - // The repeat needs to know the direction is still HELD. Was*Pressed only reports the press edge, so it - // is the fallback that degrades to one step per press when the level probes are unavailable. + // Was*Pressed degrades repeat to one step per press when level probes are absent. const bool right_down = g_input_is_right_pressed ? g_input_is_right_pressed(input) : g_input_was_right_pressed(input); - const bool left_down = g_input_is_left_pressed ? g_input_is_left_pressed(input) : g_input_was_left_pressed(input); - const int dir = right_down ? 1 : (left_down ? -1 : 0); + const bool left_down = g_input_is_left_pressed ? g_input_is_left_pressed(input) : g_input_was_left_pressed(input); + const int dir = right_down ? 1 : (left_down ? -1 : 0); if (self != g_slider_repeat_component) { - g_slider_repeat_component = self; // focus moved to another slider, so start its repeat fresh + g_slider_repeat_component = self; // focus moved, restart repeat. g_slider_repeat_dir = 0; g_slider_repeat_timer = 0.0f; } @@ -4658,7 +4330,7 @@ namespace big::mod_settings } else if (dir != g_slider_repeat_dir) { - step_now = true; // a fresh press steps at once, then waits out the delay + step_now = true; // fresh press steps immediately. g_slider_repeat_timer = slider_repeat_delay; } else @@ -4675,21 +4347,17 @@ namespace big::mod_settings if (step_now) { step_slider_row(self, row, dir); - return true; // claim only the frames that actually moved the value, like the native num-box + return true; // claim only frames that moved the value. } - return false; // the native continuous slide still never runs, it cannot land on our step grid + return false; // still block native continuous slide. } } return big::g_hooking->get_original()(self, input, dt); } - // Button-click hook. GUIComponentButton overrides GUIComponent::OnClicked (the engine's terminal click), so this is - // where our button rows' clicks land. static bool hook_GUIComponentButton_OnClicked(GUIComponent* self, std::uint64_t location) { - // Clicking the restart message box's button closes the game (forced restart). Re-validate the button's owner is - // still the restart dialog so a rebuilt row that happened to reuse the freed button's address (if the dialog - // were ever dismissed without confirming) cannot trigger it. + // Re-validate ownership so address reuse cannot trigger a forced restart. if (self && self == g_restart_confirm_button && g_restart_dialog && *reinterpret_cast(reinterpret_cast(self) + sgg::gui_component_button_owner_offset) == g_restart_dialog) { big::g_hooking->get_original()(self, location); @@ -4713,9 +4381,7 @@ namespace big::mod_settings } } - // A boolean toggle flips in our own code below rather than through the native toggle handler that plays the - // click sound, so stage the matching toggle cue as the press sound before the base OnClicked runs (it plays - // mPressSound). Predict the value the click produces (the flip of the current one) to pick the on/off cue. + // Stage the native toggle cue before base OnClicked plays mPressSound. if (matched && !matched_row.disabled && matched_row.kind == RowKind::setting && matched_row.entry && matched_row.entry->type() == typeid(bool)) { @@ -4724,17 +4390,13 @@ namespace big::mod_settings else if (matched && !matched_row.disabled && matched_row.kind == RowKind::setting && matched_row.is_virtual_input && matched_row.is_toggle) { - // Predict the flipped state for the press cue. get() drives the flip, but may be nil (value not set yet), - // so fall back to the row's last-drawn state - matching the flip below. + // Fall back to the last-drawn state when get() is nil. const auto cur = get_virtual_value(matched_row.stem, row_io_section(&matched_row), matched_row.setting_key); const bool cur_on = cur.type == virtual_value::kind::boolean ? cur.as_bool : matched_row.toggle_value; stage_toggle_press_sound(self, !cur_on); } - // A matched but disabled row (a greyed action button or a context-restricted setting that stays selectable so its - // note still shows on hover) must not react to a click. The base GUIComponent::OnClicked plays mPressSound and swaps - // the button's pressed graphic even though the row has no usable activate, so calling it would sound and visually - // "press" a control the user cannot use. + // Disabled rows stay hoverable for notes but must not play press feedback. if (matched && matched_row.disabled) { return false; @@ -4762,15 +4424,11 @@ namespace big::mod_settings { auto* entry = matched_row.entry; - // Boolean settings toggle in place. Other types open a freetext editor. Num-box rows are - // GUIComponentNumBox, so their clicks never reach this hook. if (entry && entry->type() == typeid(bool)) { const bool new_value = !entry->get_value_base(); - // Block disabling a mod that other enabled mods still depend on: turning the mod's master "enabled" - // switch off would break them. Leave the toggle on and show an informational popup listing the - // dependents (its button just dismisses the popup). + // Block disabling a mod while enabled mods still depend on it. if (matched_row.is_enabled_toggle && !new_value) { const std::vector dependents = active_dependents_of(matched_row.stem); @@ -4779,23 +4437,19 @@ namespace big::mod_settings void* owner = *reinterpret_cast(reinterpret_cast(self) + sgg::gui_component_button_owner_offset); void* screen_manager = owner ? *reinterpret_cast(reinterpret_cast(owner) + screen_manager_offset) : nullptr; show_dependency_dialog(screen_manager, build_dependency_message(dependents)); - break; // do not disable, the toggle stays on + break; // leave the toggle on. } } - // Capture the session baseline before the first write so a later revert to it (toggling off then on - // again) is recognised as "no net change". + // Capture the baseline before the first write so a revert is "no net change". capture_restart_baseline(entry); entry->set_value_base(new_value); set_toggle_graphic(self, new_value); - // If the author declared this setting restart-required, flag/clear the restart and record the - // change for the popup. note_change_if_restart_required(entry, new_value ? "on" : "off"); - // Toggling "enabled" changes greying. Toggling any bool in a dynamic view may change - // disabled/hidden/range. Rebuild in place next Update, preserving scroll. + // Toggling bools can change greying or dynamic rows, so rebuild in place. if (matched_row.is_enabled_toggle || g_view_has_dynamic) { g_pending_view = View::mod_settings; @@ -4811,8 +4465,7 @@ namespace big::mod_settings } else if (matched_row.is_virtual_input && matched_row.is_toggle) { - // Interactive virtual boolean: flip through Lua set() and repaint. After set(), get() returns the - // stored value for later clicks. + // Flip through Lua set() and repaint. const auto cur = get_virtual_value(matched_row.stem, row_io_section(&matched_row), matched_row.setting_key); const bool cur_on = cur.type == virtual_value::kind::boolean ? cur.as_bool : matched_row.toggle_value; const bool new_value = !cur_on; @@ -4823,9 +4476,7 @@ namespace big::mod_settings } case RowKind::action: - // Run the author's Lua callback, then rebuild the current view: the callback may have changed config - // values (e.g. a "Reset" button) or dynamic ranges, so the rows need to re-read them. Mirrors the - // master-toggle rebuild path. + // Lua callbacks may change config values or dynamic ranges. invoke_action(matched_row.stem, matched_row.target_section, matched_row.setting_key); g_pending_view = View::mod_settings; g_pending_stem = matched_row.stem; @@ -4839,7 +4490,6 @@ namespace big::mod_settings return result; } - // Restores vertical breathing room around action-button rows after native UpdateScrollState lays out the page. static void apply_button_spacing(MiscSettingsScreen* screen) { const std::size_t first = screen->m_page_start_index; @@ -4869,10 +4519,7 @@ namespace big::mod_settings } } - // Points the native scroll arrows at the keyboard/controller nav so it can page. The spatial search - // (SearchInDirection) walks a ray from the selected row in the pressed direction and picks the nearest selectable - // component whose eval point (location + mFreeFormSelectOffset) is close to the ray. Off the last/first page the arrow - // is hidden and unselectable, so this is inert there. + // SearchInDirection uses the arrow's free-form eval point, so aim it at the page edge row. static void enable_arrow_keyboard_paging(MiscSettingsScreen* screen) { if (g_rows.empty()) @@ -4903,9 +4550,7 @@ namespace big::mod_settings aim(screen->m_up_arrow, g_rows[first].component, -row_pitch); } - // Detour on the native scroll pass. We hook it to give the action-button rows their vertical breathing room - // (apply_button_spacing) and to re-aim the scroll arrows' keyboard-nav eval points at the new page edges after each - // layout (see enable_arrow_keyboard_paging), inside MiscSettingsScreen::Update before the row hit-test. + // Runs inside MiscSettingsScreen::Update before row hit-tests. static void hook_MiscSettingsScreen_UpdateScrollState(void* self) { big::g_hooking->get_original()(self); @@ -4919,15 +4564,12 @@ namespace big::mod_settings } } - // Per-frame screen update: RCX=this, XMM1=dt (float), R8=input. We apply any queued navigation here because the - // click/input iteration has fully unwound by now, so tearing down and rebuilding the component vectors is safe. We - // rebuild before the original runs so this frame lays out and hover-resolves the new rows. + // RCX=this, XMM1=dt, R8=input. Rebuilds are safe after click/input iteration unwinds. static void* hook_MiscSettingsScreen_Update(void* self, float dt, void* input) { auto* screen = static_cast(self); const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); - // Freetext editing refreshes here. Confirm/cancel is handled in the HandleInput hook. if (g_editing) { if (on_mods_tab) @@ -4936,15 +4578,11 @@ namespace big::mod_settings } else { - exit_edit_mode(); // safety: never stay in edit mode off the Mods tab + exit_edit_mode(); // never stay in edit mode off the Mods tab. } } - // An edit in a view with dynamic (function) rows re-evaluates them, which frees and recreates every row. That is - // deferred twice over: a debounce absorbs the per-frame slider hook and coalesces bursts (each commit re-arms - // it), and the rebuild is HELD while the user is still adjusting a row, since otherwise it would free the - // focused slider mid-adjust or interrupt a drag. build_panel clears the timer so its own rebuild cancels any - // pending one, and the !g_nav_pending guard folds this into a rebuild already queued by an instant path. + // Dynamic rows rebuild only after debounce and only once the user stops adjusting a row. if (g_dynamic_refresh_settle > 0.0f) { if (!on_mods_tab) @@ -4953,7 +4591,7 @@ namespace big::mod_settings } else if (interacting_with_row(screen, input)) { - g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; // hold until they leave the row + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; // hold until they leave the row. } else { @@ -4968,7 +4606,6 @@ namespace big::mod_settings g_pending_section = g_view_section; g_nav_pending = true; - // Pin the edited row so the rebuild keeps focus on it. if (GUIComponent* active = active_row_component(screen)) { if (const PanelRow* fr = find_row(active)) @@ -4985,9 +4622,7 @@ namespace big::mod_settings { if (on_mods_tab) { - // Stepping from a mod's settings back to the mod overview is the "done configuring this mod" point: if a - // restart-required setting changed this session, show the restart prompt now (it forces the restart) and stay on - // the current view under it, rather than returning to the overview. + // Leaving a mod's settings is the restart-required prompt point. const bool leaving_mod = (g_view == View::mod_settings) && (g_pending_view == View::mod_list); bool prompted = false; if (leaving_mod && g_restart_required && !g_restart_prompt_shown) @@ -5004,10 +4639,7 @@ namespace big::mod_settings g_nav_pending = false; } - // For a few frames after a click-triggered rebuild, pin the clicked row as hovered/selected and re-apply our - // prompt/description over the native hover pass, which settles over the new layout a frame later and would otherwise - // blink the prompt, description or highlight onto a neighbouring row. Runs before the original Update (which reads - // mMouseOverComponent for the description) so this frame is already correct. + // Pin the clicked row over the native hover pass for a few frames after rebuild. if (g_keep_active_frames > 0 && on_mods_tab) { reassert_keep_active_row(screen); @@ -5019,8 +4651,6 @@ namespace big::mod_settings void* result = big::g_hooking->get_original()(self, dt, input); - // The original just laid out the key rows for this frame. Mirror the value columns onto them so the right column - // tracks scrolling and fade, and show the highlighted row's description in the native description box. if (on_mods_tab) { sync_scroll_fade(screen); @@ -5028,13 +4658,9 @@ namespace big::mod_settings sync_description_box(screen); } - // Retune the bottom prompt buttons per context (off the Mods tab this only clears our caches and leaves the - // native prompts alone). sync_prompts(screen, on_mods_tab); - // Revert any slider/num-box highlight left stranded on the wrong row by a rebuild or the hover re-assert, and - // re-assert the greyed-label colour flag on disabled-but-selectable widget rows (the widgets clear it each - // frame from mIsUseable). + // Widgets clear greyed-label colour from mIsUseable each frame. if (on_mods_tab) { clear_stale_widget_highlight(screen); @@ -5044,10 +4670,7 @@ namespace big::mod_settings return result; } - // While a freetext setting is being edited, read Enter and Escape from the game's own per-frame input, commit or - // cancel here, then swallow the screen's input handling so menu navigation and Escape-to-close do not react. - // Committing here rather than in Update matters: returning true this frame also swallows a submitting mouse click, - // so it cannot activate the row it lands on. + // Committing here also swallows a submitting mouse click. static bool hook_MiscSettingsScreen_HandleInput(void* self, void* input, float x) { if (g_editing) @@ -5073,20 +4696,16 @@ namespace big::mod_settings { auto* menu = reinterpret_cast(screen); - // Select enters a slider/enum row (so the stick adjusts it). Toggles and buttons are left to the native - // component pass. if (g_component_focused && control_pressed(input, g_controls_select)) { PanelRow* row = find_row(menu->m_mouse_over_component); if (row && !row->disabled && (row->is_slider || row->is_enum)) { g_component_focused(screen, menu->m_mouse_over_component); - return true; // consume the enter press + return true; // consume the enter press. } } - // Back/Cancel inside a mod's settings steps back one level instead of returning to the tab bar. In the mod - // list it is left to the native handler. The restart prompt is shown by apply_nav. if (g_view == View::mod_settings && !g_nav_pending && control_pressed(input, g_controls_cancel)) { request_back_nav(); @@ -5094,9 +4713,7 @@ namespace big::mod_settings } } - // The native handler runs the keyboard/controller nav, including the on-screen scroll arrow's auto-activate at a page - // edge, which pages via ScrollDown/ScrollUp and selects the new page's edge row. Capture the page index across the - // call so we can correct that landing when it falls on a disabled row (see redirect_page_landing). + // Capture page changes so disabled edge-row landings can be corrected. const bool track_paging = on_mods_tab && !(g_use_mouse && *g_use_mouse); const std::uint32_t page_before = screen->m_page_start_index; @@ -5110,20 +4727,15 @@ namespace big::mod_settings return result; } - // Close funnel: every way the user dismisses the screen converges here, before any fade/teardown and while - // mScreenManager is valid. If a restart is required, show the message box and veto the close - the box is modal over - // the still-open screen and its button closes the game, since a restart-required change cannot be cancelled. + // Close funnel while mScreenManager is valid. static void hook_MiscSettingsScreen_ExitScreen(void* self) { - // Inside a mod's settings, Esc/controller B/the on-screen Back button steps up one level: a nested group - // returns to its parent section, and the root returns to the mod list. Only the mod-list view actually closes - // the options screen. auto* screen = static_cast(self); const bool on_mods_tab = screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button); if (on_mods_tab && g_view == View::mod_settings) { request_back_nav(); - return; // veto the close, apply_nav applies the new view next Update + return; // veto close, apply_nav runs next Update. } if (g_restart_required && !g_restart_prompt_shown) @@ -5136,21 +4748,16 @@ namespace big::mod_settings } } - // The screen is really closing now. Tear our rows down first: the engine frees a MenuScreen's components through its - // reflection helper (which our rows are deliberately not registered in), not by walking mComponents, so on close it - // would neither free nor double-free them - they would just leak. destroy_rows is a no-op when g_rows is already - // empty (e.g. closing off the Mods tab). - g_options_screen_open = false; // stop gating on_change on this now-closing screen. - g_dynamic_refresh_settle = 0.0f; // drop any pending numeric-change refresh for the closing screen. + // MenuScreen frees components through reflection, not by walking mComponents. + g_options_screen_open = false; // stop gating on_change on this screen. + g_dynamic_refresh_settle = 0.0f; // drop the pending refresh. destroy_rows(screen); exit_edit_mode(); big::g_hooking->get_original()(self); } - // Reset choke-point: sgg::MiscSettingsScreen::RestoreDefaults (virtual slot 21) is the single handler for both the - // [I]/MenuInfo control and a mouse click on the on-screen Reset button. On our Mods tab the native reset is a no-op - // (our rows' mDataValue is not a ConfigOptionsField key). + // RestoreDefaults is the handler for [I]/MenuInfo and the on-screen Reset button. static void hook_MiscSettingsScreen_RestoreDefaults(void* self) { auto* screen = static_cast(self); @@ -5158,7 +4765,7 @@ namespace big::mod_settings { if (g_view != View::mod_settings) { - return; // reset is unavailable in the mod list, do nothing (and do not play the native reset) + return; // do not play native reset in the mod list. } perform_reset(); } @@ -5172,10 +4779,7 @@ namespace big::mod_settings void register_hooks() { - // Resolve every engine symbol, RVA and offset the Mods tab depends on up front. The symbol map is built from the - // game's live PDB, so if the game updates and a required function moved or was renamed it resolves to null here. - // Likewise, the hardcoded RVAs and struct offsets this feature was reverse-engineered against only match one - // specific Ship build. + // Resolve symbols, RVAs and offsets up front against the validated Ship build. std::vector missing; const auto require = [&](const char* name) -> gmAddress { @@ -5187,7 +4791,6 @@ namespace big::mod_settings return addr; }; - // Functions we hook (installed below, once everything checks out). const auto ctor = require("sgg::MiscSettingsScreen::MiscSettingsScreen"); const auto do_show_category = require("sgg::MiscSettingsScreen::DoShowCategory"); const auto on_clicked = require("sgg::GUIComponentButton::OnClicked"); @@ -5196,9 +4799,7 @@ namespace big::mod_settings const auto handle_input = require("sgg::MiscSettingsScreen::HandleInput"); const auto set_number_value = require("sgg::GUIComponentNumBox::SetNumberValue"); - // Engine helpers called while building and editing rows. A null call here would crash, so every one is - // required. The button ctor doubles as the RVA anchor for the templated/overloaded helpers resolved further - // down. + // Required helpers. The button ctor anchors later RVA fallbacks. const auto anchor = require("sgg::GUIComponentButton::GUIComponentButton"); g_button_ctor = anchor.as_func(); g_set_label = require("sgg::GUIComponentButton::SetDisplayName").as_func(); @@ -5216,16 +4817,13 @@ namespace big::mod_settings g_push_back = big::hades2_symbol_to_address["eastl::vector::push_back"].as_func(); - // Optional helpers: every call site is null-guarded, so their absence only degrades a visual or teardown detail - // (never crashes) and must not gate the feature. + // Optional helpers are null-guarded. g_get_lines = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GetLines"].as_func(); g_set_selected_texture = big::hades2_symbol_to_address["sgg::GUIComponentButton::SetSelectedTexture"].as_func(); g_button_dtor = big::hades2_symbol_to_address["sgg::GUIComponentButton::~GUIComponentButton"].as_func(); g_disable = big::hades2_symbol_to_address["sgg::GUIComponentButton::Disable"].as_func(); - // Slider construction + drag hook (optional: if any is missing, bounded numbers fall back to the num-box stepper). - // SetFraction is both the initial set and the drag hook (installed below). The slider vtable is resolved by name (RVA - // fallback) once the build is verified. + // Slider helpers are optional, with num-box fallback for bounded numbers. g_gui_component_ctor = big::hades2_symbol_to_address["sgg::GUIComponent::GUIComponent"].as_func(); g_image_ctor = big::hades2_symbol_to_address["sgg::GUIComponentImage::GUIComponentImage"].as_func(); g_textbox_ctor = big::hades2_symbol_to_address["sgg::GUIComponentTextBox::GUIComponentTextBox"].as_func(); @@ -5233,37 +4831,31 @@ namespace big::mod_settings const auto slider_set_fraction = big::hades2_symbol_to_address["sgg::GUIComponentSlider::SetFraction"]; g_slider_set_fraction = slider_set_fraction.as_func(); - // Controller focus: ComponentFocused makes a row the focused option, and GetState reads Back/Cancel for - // drilldown back-nav. Both are optional by-name lookups. + // Optional controller focus and Back/Cancel helpers. g_component_focused = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ComponentFocused"].as_func(); g_set_mouse_over = big::hades2_symbol_to_address["sgg::MenuScreen::SetMouseOver"].as_func(); g_input_get_state = big::hades2_symbol_to_address["sgg::InputHandler::GetState"].as_func(); g_mouse_button_down = big::hades2_symbol_to_address["sgg::InputHandler::IsLeftOrRightMouseButtonDown"].as_func(); - // Left/right press edges (dpad, arrow keys and a left-stick flick fold into these), read to move our discrete - // slider rows one step per press instead of the native continuous slide. Optional - without them the sliders - // keep the native continuous keyboard/controller behaviour. + // Optional left/right press edges enable one-step slider input. g_input_was_left_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasLeftPressed"].as_func(); g_input_was_right_pressed = big::hades2_symbol_to_address["sgg::InputHandler::WasRightPressed"].as_func(); - // The level counterparts of the above, so holding a direction repeats instead of stepping once. Optional - - // without them a slider still steps, just once per press. - g_input_is_left_pressed = big::hades2_symbol_to_address["sgg::InputHandler::IsLeftPressed"].as_func(); + // Optional level probes enable held-direction repeat. + g_input_is_left_pressed = big::hades2_symbol_to_address["sgg::InputHandler::IsLeftPressed"].as_func(); g_input_is_right_pressed = big::hades2_symbol_to_address["sgg::InputHandler::IsRightPressed"].as_func(); - // Native-settings flush before a forced restart. SaveProfile persists language, volumes, graphics and gameplay - // toggles. Optional - missing symbols only mean those native edits may wait for a normal save. + // Optional native-settings flush before a forced restart. g_save_profile = big::hades2_symbol_to_address["sgg::ProfileManager::SaveProfile"].as_func(); g_active_profile = big::hades2_symbol_to_address["sgg::ProfileManager::ACTIVE_PROFILE"].as(); - // Config/control GLOBALS, resolved by name (update-proof - they are named PDB data symbols that move with - // .data/.rdata across updates, so an anchor-relative RVA cannot be trusted). None crash. + // Named PDB data symbols move with .data and .rdata, unlike anchor-relative RVAs. g_use_mouse = big::hades2_symbol_to_address["sgg::ConfigOptions::UseMouse"].as(); g_config_language = big::hades2_symbol_to_address["sgg::ConfigOptions::Language"].as(); g_controls_cancel = big::hades2_symbol_to_address["sgg::Controls::Cancel"].as(); g_controls_select = big::hades2_symbol_to_address["sgg::Controls::Select"].as(); - // The game's CRT heap (see game_alloc). Missing means disable, never fall back to H2M's own CRT. + // Never fall back from the game's CRT heap to H2M's CRT. if (HMODULE ucrt = ::GetModuleHandleW(L"ucrtbase.dll")) { g_game_aligned_malloc = reinterpret_cast(::GetProcAddress(ucrt, "_aligned_malloc")); @@ -5274,24 +4866,19 @@ namespace big::mod_settings missing.push_back("ucrtbase.dll _aligned_malloc/_aligned_free (the game's CRT heap)"); } - // The hardcoded RVAs and struct offsets above are valid only for the build they were captured against. - // We gate the (attempted) creation of the menu itself on a valid GUID to not crash the game unnecessarily. - // The config API itself works regardless. + // Hardcoded RVAs and struct offsets are valid only for allow-listed PDB GUIDs. static constexpr const char* validated_pdb_guids[] = { - "744ea71c-2c21-4b40-a6c486d1fa6647da", // Ship, 2026-08-04 + "744ea71c-2c21-4b40-a6c486d1fa6647da", // Ship, 2026-08-04. }; - const bool build_validated = std::find(std::begin(validated_pdb_guids), std::end(validated_pdb_guids), big::hades2_pdb_guid) - != std::end(validated_pdb_guids); + const bool build_validated = std::find(std::begin(validated_pdb_guids), std::end(validated_pdb_guids), big::hades2_pdb_guid) != std::end(validated_pdb_guids); - // Secondary sanity check on top of the GUID allow-list: the anchor (button ctor) must sit at its known module - // RVA. A matching GUID already implies this, so a failure here means the PDB and the loaded exe disagree (e.g. - // a mismatched/hand-swapped PDB), which would make every RVA/offset untrustworthy. + // A GUID match plus anchor RVA match guards against PDB/exe mismatch. uintptr_t game_base = 0; std::size_t game_size = 0; ::module_info_helper::get_module_base_and_size(&game_base, &game_size, nullptr); const bool build_matches = anchor && game_base && (anchor.as() - game_base == anchor_rva); - // push_back is a named PDB symbol but is occasionally emitted inline, so fall back to its RVA. + // push_back can be absent as a named symbol, so fall back to its RVA. if (!g_push_back && build_matches) { g_push_back = reinterpret_cast(anchor.as() - anchor_rva + push_back_rva); @@ -5326,16 +4913,14 @@ namespace big::mod_settings return; } - // Build verified and every required symbol resolved: derive the remaining anchor-relative helpers and hook. - // These are .text functions that cannot be picked unambiguously by name, plus TeleportCursorTo. + // These helpers cannot be picked unambiguously by name. const auto anchor_base = anchor.as() - anchor_rva; g_message_dialog_ctor = reinterpret_cast(anchor_base + message_dialog_ctor_rva); g_add_screen = reinterpret_cast(anchor_base + add_screen_rva); g_numbox_factory = reinterpret_cast(anchor_base + numbox_factory_rva); g_teleport_cursor = reinterpret_cast(anchor_base + teleport_cursor_rva); - // Slider vtable: prefer the named public symbol (update-proof), fall back to the anchor-relative RVA (which - // lives in .rdata and shifts on updates) only if the vtable is absent from the symbol map. + // Prefer the named slider vtable, then fall back to the anchor-relative .rdata RVA. if (const auto slider_vt = big::hades2_symbol_to_address["??_7GUIComponentSlider@sgg@@6B@"]; slider_vt) { g_slider_vtable = slider_vt.as(); @@ -5345,10 +4930,7 @@ namespace big::mod_settings g_slider_vtable = anchor_base + slider_vtable_rva; } - // Build a patched copy of the slider vtable whose GetArea/GetScreenArea slots return a one-row hit rect (see - // build_row_area_vtable). The native slider GetArea unions the slider's sub-components into a screen-spanning - // rectangle that, through the nearest-anchor hover tiebreak, hijacks mouse hover (and keyboard nav) from other rows - // on a mixed page. + // Native slider GetArea spans the screen and hijacks hover from other rows. if (g_slider_vtable) { g_slider_vtable_patched = build_row_area_vtable(g_slider_vtable_copy, sizeof(g_slider_vtable_copy), g_slider_vtable); @@ -5363,9 +4945,7 @@ namespace big::mod_settings "sgg::MiscSettingsScreen::DoShowCategory", do_show_category); - // All required by the checks above, so install unconditionally. OnClicked and SetNumberValue are global (they - // fire for every button/num-box in the game). Their callbacks filter to our rows via find_row, so installing - // them is a no-op for the rest of the game's UI. + // Global button and num-box hooks filter to our rows via find_row. static auto onclick_hook = hooking::detour_hook_helper::add_queue( "sgg::GUIComponentButton::OnClicked", on_clicked); @@ -5373,14 +4953,12 @@ namespace big::mod_settings "sgg::GUIComponentNumBox::SetNumberValue", set_number_value); - // Optional: persists user drags on our slider rows (filtered to our rows via find_row, so it is a no-op for the - // native audio sliders). If absent, bounded numbers render as the num-box stepper. + // Optional slider drag hook. if (slider_set_fraction) { static auto set_fraction_hook = hooking::detour_hook_helper::add_queue("sgg::GUIComponentSlider::SetFraction", slider_set_fraction); - // Discrete keyboard/controller stepping needs the left/right probes. Without them our slider rows keep - // the native continuous slide, so only install the input override when both resolved. + // Only override slider input when both left/right probes resolved. const auto slider_handle_input = big::hades2_symbol_to_address["sgg::GUIComponentSlider::HandleInput"]; if (slider_handle_input && g_input_was_left_pressed && g_input_was_right_pressed) { @@ -5394,8 +4972,7 @@ namespace big::mod_settings "sgg::MiscSettingsScreen::HandleInput", handle_input); - // Every close path (Escape key, controller B, clicking the on-screen Exit button) funnels through ExitScreen, - // so this is where the restart-required prompt is triggered. + // ExitScreen is the restart-required prompt funnel. const auto exit_screen = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::ExitScreen"]; if (exit_screen) { @@ -5407,8 +4984,7 @@ namespace big::mod_settings "will not appear"; } - // Optional: the on-screen "Reset" button ([I]/MenuInfo control or mouse) funnels through RestoreDefaults. - // Without it the Mods tab still works. Reset just won't restore mod defaults. + // Optional RestoreDefaults hook for the Reset button. const auto restore_defaults = big::hades2_symbol_to_address["sgg::MiscSettingsScreen::RestoreDefaults"]; if (restore_defaults) { diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index a52b094..e8a3f5e 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -10,13 +10,10 @@ namespace big::mod_settings void register_hooks(); void bind_config_api(sol::state_view& state, sol::table& lua_ext); - // A user-facing string, plain or localized: language-code -> text, with a plain string under the empty key. - // Resolved to the current game language at render, falling back to English then any entry. + // Plain text or language-code -> text, with a plain string under the empty key. using localized_text = std::map; - // Where a setting may be edited: anywhere, only from the main menu, only while a save is loaded, or only in the hub - // (the Crossroads). Off-context rows are greyed with a note. main_menu is forced for the master "enabled" toggle and - // any restartRequired setting. + // Off-context rows are greyed. main_menu is forced for the master toggle and restartRequired settings. enum class editable_context { any, @@ -25,8 +22,7 @@ namespace big::mod_settings in_hub, }; - // Forces a virtual row's widget kind (config.lua `type`) when it cannot be inferred from get(). Ignored for - // config-backed settings. `enumeration` is only needed without a `values` list. + // Pins a virtual row's widget kind when get() cannot provide one. enum class widget_type { inferred, @@ -36,38 +32,33 @@ namespace big::mod_settings enumeration, }; - // An author-declared menu category (configDesc `groups`) with no matching config section. `id` is what a per-entry - // `group` path references. + // Author-declared category with no matching config section. struct menu_group { std::string id; localized_text name; localized_text description; localized_text disabled_description; - bool has_order = false; - double order = 0.0; - bool disabled = false; + bool has_order = false; + double order = 0.0; + bool disabled = false; editable_context context = editable_context::any; - bool has_dynamic = false; + bool has_dynamic = false; std::vector children; }; - // The author-declared menu group tree for mod `guid`, empty when none was declared. std::vector mod_menu_groups(const std::string& guid); - // Re-resolves one author-declared group's dynamic fields against the current game state. `path` is its id chain - // under the root configDesc `groups` (e.g. { "debugging", "logging" }). + // Re-resolves one menu group's dynamic fields against the current game state. std::optional resolve_menu_group(const std::string& guid, const std::vector& path); - // Author-declared metadata for one setting, from its config.lua description table. Only settings described with a - // rich table get an entry - the rest fall back to type-based rendering. All fields are optional (see the has_*). + // Metadata from a rich config.lua description table. All fields are optional. struct setting_metadata { - localized_text name; // display-name override (empty -> prettified key) - localized_text description; // same text written to the .cfg comment + localized_text name; // display-name override. + localized_text description; // .cfg comment text. - // Shown instead of `description` while the row is greyed by its own `disabled` (empty -> use `description`). - // Not applied to a context-restricted or mod-disabled row, which show their own note. + // Author-disabled rows may override `description`. localized_text disabled_description; bool has_min = false; @@ -77,101 +68,84 @@ namespace big::mod_settings bool has_step = false; double step = 0.0; - // Enum options and their parallel display labels (labels default to the values). Serialized like the config - // entry's value. + // Enum options serialize like config values. Labels default to values. std::vector values; std::vector labels; bool has_order = false; - double order = 0.0; // author-declared sort key (lowest first), unset -> alphabetical by display name + double order = 0.0; // lowest first, unset means alphabetical by display name. - bool hidden = false; // author asked to omit this row entirely - bool disabled = false; // render greyed and non-interactive but still visible (may be dynamic) - bool restart_required = false; // change only takes effect after a game restart + bool hidden = false; + bool disabled = false; // greyed and non-interactive, may be dynamic. + bool restart_required = false; editable_context context = editable_context::any; - // A field written as a Lua function, skipped at load and re-evaluated at render via resolve_setting_metadata. + // Lua-function fields are re-evaluated at render. bool has_dynamic = false; - // is_percentage shows a 0..1 value as 0..100 and appends "%", show_as_percentage only appends it. Neither - // changes the stored value. + // is_percentage scales display by 100. show_as_percentage only appends "%". bool show_as_percentage = false; bool is_percentage = false; - // Virtual-row only. `default` is the value a menu Reset restores through set(), serialized like an enum option. + // Virtual-row only. Reset restores `default` through set(). widget_type type = widget_type::inferred; bool has_default = false; std::string default_value; - // Menu path this entry appears under instead of its config section (configDesc `group`), empty -> its config - // section. Each segment is a config child section or a declared author group. + // Empty means config-section placement. Segments are config child sections or declared groups. std::vector group; }; - // True if the author declared this setting as requiring a game restart to take effect. bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key); - // The author-declared metadata for a setting, or nullopt when it has no rich metadata table (the menu then renders - // it with type-based defaults). std::optional get_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); - // True if (section, key) carries a configDesc entry. The menu shows only described keys, plus the master "enabled" - // toggle regardless. bool setting_is_described(const std::string& guid, const std::string& section, const std::string& key); - // True when the game is in the hub (the Crossroads), i.e. the game Lua global `CurrentHubRoom` is non-nil. Gates - // `editableContext = "inHub"` rows. + // Gates `editableContext = "inHub"` rows via the game's `CurrentHubRoom` global. bool game_is_in_hub(); - // Like get_setting_metadata, but re-evaluates the setting's dynamic fields against the current game state. Call - // when get_setting_metadata reports has_dynamic. The result never has has_dynamic set. + // Re-evaluates dynamic fields against the current game state. Result has no dynamic fields. std::optional resolve_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); - // A configDesc entry with an `action` function and no config value: a button that runs a Lua callback. + // Button backed by a configDesc `action` function with no config value. struct action_info { - std::string section; // config section the action lives in (drilldown level) - std::string key; // description key of the action - localized_text name; // button label (display_name, or the prettified key) - localized_text description; // help text shown while highlighted - localized_text disabled_description; // shown instead of `description` while author-disabled (may be dynamic) - bool has_order = false; // author-declared sort key present + std::string section; + std::string key; + localized_text name; + localized_text description; + localized_text disabled_description; // author-disabled override, may be dynamic. + bool has_order = false; double order = 0.0; - editable_context context = editable_context::any; // when the button is enabled (main-menu vs in-save) - bool disabled = false; // greyed and non-interactive (author-declared, may be dynamic) - bool has_dynamic = false; // name/description/order/disabled is a Lua function - std::vector group; // menu placement override (configDesc `group`), empty -> config section + editable_context context = editable_context::any; + bool disabled = false; // greyed and non-interactive, may be dynamic. + bool has_dynamic = false; // Lua-function fields are re-evaluated. + std::vector group; // empty means config-section placement. }; - // The action buttons declared directly in config `section` of mod `guid` (not recursing), with their dynamic fields - // resolved against the current game state. + // Returned action fields are resolved against the current game state. std::vector get_actions(const std::string& guid, const std::string& section); - // Runs an action button's Lua callback protected, logging errors. No-op if (guid, section, key) is not an action. void invoke_action(const std::string& guid, const std::string& section, const std::string& key); - // A configDesc entry with NO backing config value, marked `virtual = true`. Its value comes from Lua callbacks: a - // read-only row uses `text`, an interactive row `get`/`set`. The rest of its metadata is read like a setting's. + // Row with no backing config value. Read-only uses `text`, interactive uses `get`/`set`. struct virtual_row_info { std::string section; std::string key; - bool has_order = false; - double order = 0.0; - bool has_dynamic = false; // a name/description/values/min/max/text field is a Lua function (re-resolve at render) - bool interactive = false; // has a `set` callback (an editable get/set row) rather than a read-only `text` row - std::vector group; // menu placement override (configDesc `group`), empty -> config section + bool has_order = false; + double order = 0.0; + bool has_dynamic = false; // Lua-function fields are re-evaluated. + bool interactive = false; // uses get/set instead of read-only text. + std::vector group; // empty means config-section placement. }; - // The virtual rows declared directly in config `section` of mod `guid` (not recursing). Order is unspecified - the - // menu sorts rows itself. std::vector get_virtual_rows(const std::string& guid, const std::string& section); - // The display string for a READ-ONLY virtual row, from its `text` callback. Empty if it has none. std::string get_virtual_display(const std::string& guid, const std::string& section, const std::string& key); - // A virtual row's current typed value. The kind decides which widget an interactive virtual row builds. struct virtual_value { enum class kind @@ -187,27 +161,20 @@ namespace big::mod_settings std::string as_string; }; - // Reads an interactive virtual row's value through its `get()` callback. kind::none if it has no `get` or the call - // fails. + // kind::none means no get() or a failed call. virtual_value get_virtual_value(const std::string& guid, const std::string& section, const std::string& key); - // Writes a virtual row's value through its `set(value)` callback. No-op when it has no `set`. void set_virtual_value(const std::string& guid, const std::string& section, const std::string& key, const virtual_value& value); - // Restores one interactive virtual row to its declared `default` through set(), returning whether it changed. The - // menu Reset scopes which rows to restore by menu path and calls this per row. + // Restores an interactive virtual row's declared `default` through set(). bool reset_virtual_row_to_default(const std::string& guid, const std::string& section, const std::string& key); - // The config.lua default for a setting bound via rom.mod_settings.load, serialized like the config entry's value. std::optional get_setting_default(const std::string& guid, const std::string& section, const std::string& key); - // True if a mod called rom.mod_settings.opt_out(). The menu still lists it, but greys its row and blocks drilling in. bool mod_opted_out(const std::string& guid); - // The custom description passed to opt_out(), shown in place of the generic note. Empty when none was given. localized_text mod_opt_out_description(const std::string& guid); - // True while a setting change should fire its mod's onChanged callback: a native options screen is open, so menu - // edits notify the mod but its own config writes do not. + // True while menu edits should fire mod onChanged callbacks. bool on_change_callbacks_enabled(); } // namespace big::mod_settings diff --git a/src/hades2/mod_settings/sgg_gui.hpp b/src/hades2/mod_settings/sgg_gui.hpp index 53a418f..fca5c5b 100644 --- a/src/hades2/mod_settings/sgg_gui.hpp +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -3,13 +3,10 @@ #include #include -// Minimal views over the native option-screen GUI objects, limited to the fields this feature reads or writes. Only -// sgg::GUIComponent base fields and MiscSettingsScreen members are used, which stay stable across the button-layout -// changes that occur between game versions. +// Native option-screen GUI views limited to fields this feature reads or writes. namespace big::mod_settings::sgg { - // Two floats, 8 bytes. As a function argument this is an integer-class aggregate, so it is passed in a - // general-purpose register rather than an XMM one - the by-value POD typing reproduces that ABI. + // Passed by value in a general-purpose register, not XMM. struct Vec2 { float x; @@ -18,8 +15,7 @@ namespace big::mod_settings::sgg static_assert(sizeof(Vec2) == 8); - // eastl::vector stores three pointers (begin, end, capacity) followed by its allocator. begin/end are enough to - // iterate an existing vector. + // eastl::vector stores begin, end, capacity, then its allocator. template struct eastl_vector { @@ -47,7 +43,6 @@ namespace big::mod_settings::sgg struct GUIComponentButton; - // sgg::GUIComponent, the base of every menu widget. struct GUIComponent { char m_pad0[0x0C]; @@ -81,26 +76,20 @@ namespace big::mod_settings::sgg static_assert(offsetof(GUIComponent, m_id) == 0x5'38); static_assert(sizeof(GUIComponent) == 0x5'40); - // Byte offset of GUIComponentButton::mOwner (MenuScreen*), set after construction. + // GUIComponentButton::mOwner (MenuScreen*) is set after construction. inline constexpr std::size_t gui_component_button_owner_offset = 0x5'A0; inline constexpr std::size_t gui_component_button_size = 0x5'B0; - // IsSelectable returns this, and MenuScreen::SetMouseOver skips a component whose IsSelectable is false - so - // clearing it makes a button non-hoverable and non-selectable. + // IsSelectable returns this. Clearing it also prevents mouse-over selection. inline constexpr std::size_t gui_component_button_selectable_offset = 0x5'51; - // mUnderMouseTexture: Draw paints this hover-highlight overlay only when it is valid and mIsUseable is set. A greyed - // but still hoverable action clears it so it does not flash a clickable-looking glow. The selection overlay - // mSelectedTexture is at 0x564, cleared via SetSelectedTexture. + // Draw paints mUnderMouseTexture only when valid and mIsUseable is set. mSelectedTexture is at 0x564. inline constexpr std::size_t gui_component_button_under_mouse_texture_offset = 0x5'68; - // mDisplayNameId, a 32-bit interned-string id. UseDefaultText resolves it back to its interned string, looks that up - // in the localized text data and sets the label from the result. It re-runs on every localization pass, including a - // live language change, so this id - not any string handed to SetDisplayName - determines the persistent label. + // mDisplayNameId is the persistent localized label source across localization passes. inline constexpr std::size_t gui_component_button_display_name_id_offset = 0x1'68; - // mComponents owns every live widget that is drawn and hit-tested; freed components are dropped from it. mAnchor is - // the base location the engine gives freshly created option components. + // mComponents owns live drawn and hit-tested widgets. mAnchor seeds new option component locations. struct MenuScreen { char m_pad_anchor[0x50]; @@ -121,8 +110,7 @@ namespace big::mod_settings::sgg static_assert(offsetof(MenuScreen, m_cancel_button) == 0x1'A8); static_assert(offsetof(MenuScreen, m_selected_component) == 0x1'B0); - // The native tabbed options screen. Category buttons are laid out contiguously from +0x388 (Gameplay) to +0x3F8 - // (Debug), the non-user categories such as Editor following the eight user-facing ones. + // Native tabbed options screen. Category buttons run contiguously from +0x388 to +0x3F8. struct MiscSettingsScreen { char m_pad_psi[0x3'44]; From b49c1401500fa0102efeae60ef6883982b11ad34 Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:25:19 +0200 Subject: [PATCH 084/100] Added note --- docs/mod_settings/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index aa35f53..a820136 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -1,5 +1,7 @@ # In-game mod settings - IDE schema & hints +> Note: Load your config using `config = rom.mod_settings.load("config.lua")` in your `main.lua` to benefit from the advanced features below. + Hell2Modding renders each mod's config file as a tab in the game's Options screen. Mods declare how their settings look and read/write their values through a `config.lua` that returns two tables: From 5ca1142cd15a231387d3fbeb56ad6274b7a2865a Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:03:51 +0200 Subject: [PATCH 085/100] Fixed hot reload --- docs/mod_settings/README.md | 11 ++- src/hades2/mod_settings/config_api.cpp | 102 ++++++++++++++++++----- src/hades2/mod_settings/mod_settings.cpp | 11 +-- src/hades2/mod_settings/mod_settings.hpp | 4 + 4 files changed, 99 insertions(+), 29 deletions(-) diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md index a820136..3fb4d3e 100644 --- a/docs/mod_settings/README.md +++ b/docs/mod_settings/README.md @@ -72,14 +72,19 @@ is opened and refreshed (after any other setting is changed). This lets a settin state or to other settings. The function runs in your mod's environment, so it can read your `config`, and call functions in your `mod` or the `game` namespace. +One thing to watch out for in callbacks: Guard any calls to functions in your mod namespace: Write `mod and mod.Thing` +rather than `mod.Thing`. This is needed as these callbacks are registered independently of the mod's enabled state, +so if your mod was disabled on startup, and the user then enables it in the mod menu, any callbacks would error as +these functions are not yet registered. + Examples: ```lua revive_count = { displayName = "Allowed Revives", min = 2, - -- Max could be dependent on internal mod state - max = function() return mod.CalcNumAllowedRevives() end, + -- Max could be dependent on internal mod state, which is unset if the mod is disabled on startup + max = function() return (mod and mod.CalcNumAllowedRevives()) or 2 end, -- Perhaps mod.CalcNumAllowedRevives() accesses the game's GameState, in which case it would error when called in the Main Menu editableContext = "inSave", }, @@ -89,7 +94,7 @@ revive_chance = { min = 0, max = 100, -- Row is greyed/disabled unless another config value is toggled on - disabled = function() return not mod.config.easy_mode end, + disabled = function() return not config.easy_mode end, disabledDescription = "Enable \"Easy Mode\" above to change this.", }, ``` diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index ff0a0df..8ff3b7d 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -807,8 +807,27 @@ namespace big::mod_settings #pragma region Config entry access and change hooks + // A mod's config_file is destroyed and rebuilt on every hot reload and Lua state reset, and a stale one can + // briefly outlive a reload, so the most recently registered file for a guid is the live one. + toml_v2::config_file* live_config_file(const std::string& guid) + { + toml_v2::config_file* found = nullptr; + for (auto* cfg : toml_v2::config_file::g_config_files) + { + if (cfg && cfg->m_config_file_stem_as_str == guid) + { + found = cfg; + } + } + return found; + } + static toml_v2::config_file::config_entry_base* find_entry(toml_v2::config_file* cf, const std::string& section, const std::string& key) { + if (!cf) + { + return nullptr; + } toml_v2::config_definition def(section, key); return cf->try_get_entry(def); } @@ -816,6 +835,10 @@ namespace big::mod_settings // Treats a section as present if it has bound leaves or child sections. static bool has_section(toml_v2::config_file* cf, const std::string& section) { + if (!cf) + { + return false; + } const std::string prefix = section + "."; for (const auto& [def, entry] : cf->m_entries) { @@ -909,16 +932,24 @@ namespace big::mod_settings static constexpr const char* k_proxy_cf_map = "h2m_mod_config_cf"; static constexpr const char* k_proxy_section_map = "h2m_mod_config_section"; - static sol::object make_proxy(sol::this_state ts, toml_v2::config_file* cf, const std::string& section); + static sol::object make_proxy(sol::this_state ts, const std::string& guid, const std::string& section); - // Live config view. The mod owns the config_file and recreates it with each Lua state. + // Live config view. The mod's config_file is destroyed and rebuilt on every hot reload and Lua state reset, so + // the proxy stores the owning guid and looks the file up on each access instead of holding a pointer that would + // be left dangling. struct mod_config_proxy { - toml_v2::config_file* cf = nullptr; + std::string guid; std::string section; + toml_v2::config_file* file() const + { + return live_config_file(guid); + } + sol::object index(sol::this_state ts, const std::string& key) const { + auto* cf = file(); if (auto* entry = find_entry(cf, section, key)) { return entry_get(ts, entry); @@ -926,13 +957,14 @@ namespace big::mod_settings const std::string child = section + "." + key; if (has_section(cf, child)) { - return make_proxy(ts, cf, child); + return make_proxy(ts, guid, child); } return sol::lua_nil; } void new_index(const std::string& key, const sol::object& value) const { + auto* cf = file(); if (auto* entry = find_entry(cf, section, key)) { entry_set(entry, value); @@ -943,7 +975,7 @@ namespace big::mod_settings const std::string child = section + "." + key; if (value.is() && has_section(cf, child)) { - const mod_config_proxy child_proxy{cf, child}; + const mod_config_proxy child_proxy{guid, child}; for (const auto& [k, v] : value.as()) { if (k.get_type() == sol::type::string) @@ -960,6 +992,11 @@ namespace big::mod_settings sol::table out = lua.create_table(); const std::string prefix = section + "."; std::set seen_children; + auto* cf = file(); + if (!cf) + { + return out; + } for (const auto& [def, entry] : cf->m_entries) { if (def.m_section == section) @@ -972,7 +1009,7 @@ namespace big::mod_settings def.m_section.substr(prefix.size(), def.m_section.find('.', prefix.size()) - prefix.size()); if (seen_children.insert(child).second) { - out[child] = make_proxy(ts, cf, prefix + child); + out[child] = make_proxy(ts, guid, prefix + child); } } } @@ -983,6 +1020,11 @@ namespace big::mod_settings std::size_t length() const { std::size_t n = 0; + auto* cf = file(); + if (!cf) + { + return n; + } for (const auto& [def, entry] : cf->m_entries) { long index = 0; @@ -1010,6 +1052,7 @@ namespace big::mod_settings sol::state_view lua(ts); sol::table sequence = lua.create_table(); const std::size_t n = length(); + auto* cf = file(); for (std::size_t i = 1; i <= n; ++i) { if (auto* entry = find_entry(cf, section, std::to_string(i))) @@ -1041,7 +1084,7 @@ namespace big::mod_settings i = index.as(); } const long next_index = i + 1; - if (auto* entry = find_entry(cf, section, std::to_string(next_index))) + if (auto* entry = find_entry(file(), section, std::to_string(next_index))) { return std::make_tuple(sol::make_object(ts, next_index), entry_get(ts, entry)); } @@ -1049,19 +1092,35 @@ namespace big::mod_settings } }; - sol::object make_proxy(sol::this_state ts, toml_v2::config_file* cf, const std::string& section) + static sol::object install_proxy(sol::this_state ts, sol::table target, const std::string& guid, const std::string& section) { sol::state_view lua(ts); sol::table registry = lua.registry(); + + std::vector keys; + for (const auto& [k, v] : target) + { + keys.push_back(k); + } + for (const auto& k : keys) + { + target[k] = sol::lua_nil; + } + + sol::table metatable = registry[k_proxy_metatable]; + sol::table cf_map = registry[k_proxy_cf_map]; + sol::table section_map = registry[k_proxy_section_map]; + target[sol::metatable_key] = metatable; + cf_map[target] = guid; + section_map[target] = section; + return target; + } + + sol::object make_proxy(sol::this_state ts, const std::string& guid, const std::string& section) + { + sol::state_view lua(ts); // The empty wrapper keeps state in weak-keyed maps, so rawpairs stays empty. - sol::table wrapper = lua.create_table(); - sol::table metatable = registry[k_proxy_metatable]; - sol::table cf_map = registry[k_proxy_cf_map]; - sol::table section_map = registry[k_proxy_section_map]; - wrapper[sol::metatable_key] = metatable; - cf_map[wrapper] = cf; - section_map[wrapper] = section; - return wrapper; + return install_proxy(ts, lua.create_table(), guid, section); } static mod_config_proxy recover(sol::this_state ts, const sol::table& wrapper) @@ -1070,9 +1129,9 @@ namespace big::mod_settings sol::table registry = lua.registry(); sol::table cf_map = registry[k_proxy_cf_map]; sol::table section_map = registry[k_proxy_section_map]; - toml_v2::config_file* cf = cf_map[wrapper]; + const std::string guid = cf_map[wrapper]; const std::string section = section_map[wrapper]; - return mod_config_proxy{cf, section}; + return mod_config_proxy{guid, section}; } // Chalk stringifies numeric config keys. @@ -1333,7 +1392,12 @@ namespace big::mod_settings g_menu_groups[guid] = std::move(menu_groups); } - return make_proxy(ts, cf.get(), "config"); + // Reuses the mod's own config table so `config` stays the same object it declared, now reading live values. + if (defaults.is()) + { + return install_proxy(ts, defaults.as(), guid, "config"); + } + return make_proxy(ts, guid, "config"); } #pragma endregion diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 1bcc80a..46dcea9 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2412,7 +2412,7 @@ namespace big::mod_settings for (auto* cfg : toml_v2::config_file::g_config_files) { - if (!cfg || cfg->m_config_file_stem_as_str != stem) + if (!cfg || cfg->m_config_file_stem_as_str != stem || cfg != live_config_file(stem)) { continue; } @@ -3836,7 +3836,8 @@ namespace big::mod_settings toml_v2::config_file* mod_cfg = nullptr; // for virtual-row path resolution. for (auto* cfg : toml_v2::config_file::g_config_files) { - if (!cfg || cfg->m_config_file_stem_as_str.empty() || cfg->m_config_file_stem_as_str != g_view_stem) + if (!cfg || cfg->m_config_file_stem_as_str.empty() || cfg->m_config_file_stem_as_str != g_view_stem + || cfg != live_config_file(g_view_stem)) { continue; } @@ -4061,12 +4062,8 @@ namespace big::mod_settings static bool mod_is_enabled(const std::string& guid) { - for (auto* cfg : toml_v2::config_file::g_config_files) + if (auto* cfg = live_config_file(guid)) { - if (!cfg || cfg->m_config_file_stem_as_str != guid) - { - continue; - } for (auto& [key, entry] : cfg->m_entries) { if (entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index e8a3f5e..adc136d 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -10,6 +10,10 @@ namespace big::mod_settings void register_hooks(); void bind_config_api(sol::state_view& state, sol::table& lua_ext); + // The live config file for a mod, or nullptr when it has none registered. A mod's config_file is destroyed and + // rebuilt on every hot reload and Lua state reset, so it must never be cached across those. + toml_v2::config_file* live_config_file(const std::string& guid); + // Plain text or language-code -> text, with a plain string under the empty key. using localized_text = std::map; From 9edd8c7410f1cf7d08ff65780c25d3dfcdb9f18c Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:06:23 +0200 Subject: [PATCH 086/100] Additional Chalk parity fixes --- src/hades2/mod_settings/config_api.cpp | 32 ++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 8ff3b7d..9b7988c 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -932,6 +932,10 @@ namespace big::mod_settings static constexpr const char* k_proxy_cf_map = "h2m_mod_config_cf"; static constexpr const char* k_proxy_section_map = "h2m_mod_config_section"; + // Chalk writes this placeholder key into expandable sections and hides it from reads and iteration. A config + // migrating from Chalk still has them in its .cfg, so they stay hidden here too. + static constexpr const char* section_empty_key = "..."; + static sol::object make_proxy(sol::this_state ts, const std::string& guid, const std::string& section); // Live config view. The mod's config_file is destroyed and rebuilt on every hot reload and Lua state reset, so @@ -949,6 +953,10 @@ namespace big::mod_settings sol::object index(sol::this_state ts, const std::string& key) const { + if (key == section_empty_key) + { + return sol::lua_nil; + } auto* cf = file(); if (auto* entry = find_entry(cf, section, key)) { @@ -964,6 +972,10 @@ namespace big::mod_settings void new_index(const std::string& key, const sol::object& value) const { + if (key == section_empty_key) + { + return; + } auto* cf = file(); if (auto* entry = find_entry(cf, section, key)) { @@ -971,9 +983,9 @@ namespace big::mod_settings return; } - // Assigning a table to a nested section writes only existing bound leaves. + // Chalk binds an unknown key on assignment instead of dropping it, and does so recursively for tables. const std::string child = section + "." + key; - if (value.is() && has_section(cf, child)) + if (value.is()) { const mod_config_proxy child_proxy{guid, child}; for (const auto& [k, v] : value.as()) @@ -983,6 +995,18 @@ namespace big::mod_settings child_proxy.new_index(k.as(), v); } } + return; + } + if (!cf) + { + return; + } + switch (value.get_type()) + { + case sol::type::boolean: cf->bind(section, key, value.as(), ""); break; + case sol::type::number: cf->bind(section, key, value.as(), ""); break; + case sol::type::string: cf->bind(section, key, value.as(), ""); break; + default: break; } } @@ -999,6 +1023,10 @@ namespace big::mod_settings } for (const auto& [def, entry] : cf->m_entries) { + if (def.m_key == section_empty_key) + { + continue; + } if (def.m_section == section) { out[def.m_key] = entry_get(ts, entry.get()); From 11c4a89aef594293b77a47a98b55f077742e6c1a Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:01:26 +0200 Subject: [PATCH 087/100] Only show declared keys in the menu --- src/hades2/mod_settings/config_api.cpp | 22 +++++++++++++ src/hades2/mod_settings/mod_settings.cpp | 39 +++++++++++++++++++----- src/hades2/mod_settings/mod_settings.hpp | 7 +++++ 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 9b7988c..95c334a 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -46,6 +46,9 @@ namespace big::mod_settings // Author-declared menu categories that do not correspond to config sections. static std::map> g_menu_groups; + // Guids that loaded their settings through mod_settings.load. + static std::set g_mod_settings_mods; + static constexpr const char* root_section = "config"; static std::string metadata_key(const std::string& guid, const std::string& section, const std::string& key) @@ -101,6 +104,24 @@ namespace big::mod_settings return g_described_keys.contains(metadata_key(guid, section, key)); } + // True while the mod loaded its settings through mod_settings.load rather than Chalk. + bool mod_declares_settings(const std::string& guid) + { + std::scoped_lock lock(g_metadata_mutex); + return g_mod_settings_mods.contains(guid); + } + + // True while the key is one the mod declared in its config table this session. + bool setting_is_declared(const std::string& guid, const std::string& section, const std::string& key) + { + std::scoped_lock lock(g_metadata_mutex); + if (!g_mod_settings_mods.contains(guid)) + { + return true; + } + return g_setting_default.contains(metadata_key(guid, section, key)); + } + std::optional get_setting_default(const std::string& guid, const std::string& section, const std::string& key) { std::scoped_lock lock(g_metadata_mutex); @@ -1418,6 +1439,7 @@ namespace big::mod_settings g_actions[guid] = std::move(actions); g_virtual_rows[guid] = std::move(virtual_rows); g_menu_groups[guid] = std::move(menu_groups); + g_mod_settings_mods.insert(guid); } // Reuses the mod's own config table so `config` stays the same object it declared, now reading live values. diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 46dcea9..7c14fe5 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2424,19 +2424,28 @@ namespace big::mod_settings continue; } - if (!out.enabled_entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) + // Keys left in the .cfg that the mod no longer declares are not part of its settings. + if (!setting_is_declared(stem, key.m_section, key.m_key)) { - out.enabled_entry = entry.get(); + continue; } - // Undescribed keys stay hidden, except the master "enabled" toggle. + // Undescribed keys stay hidden. The master toggle is exempt only for mods that declare their settings, + // since Chalk re-binds stale .cfg keys with an empty description and an old "enabled" would resurface. const bool is_enabled_toggle = key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key); - if (!is_enabled_toggle && !setting_is_described(stem, key.m_section, key.m_key) + const bool desc_exempt = is_enabled_toggle && mod_declares_settings(stem); + if (!desc_exempt && !setting_is_described(stem, key.m_section, key.m_key) && !entry_has_description(entry.get())) { continue; } + // Only a toggle that is actually shown may mark the mod disabled. + if (!out.enabled_entry && is_enabled_toggle) + { + out.enabled_entry = entry.get(); + } + const auto static_meta = get_setting_metadata(stem, key.m_section, key.m_key); const std::vector grp = static_meta ? static_meta->group : std::vector{}; std::string child_path; @@ -3854,8 +3863,15 @@ namespace big::mod_settings } auto* e = entry.get(); + // Reset must not touch keys the mod no longer declares, matching what the menu shows. + if (!setting_is_declared(guid, def.m_section, def.m_key)) + { + continue; + } + const bool is_enabled_toggle = def.m_section == root_section && e->type() == typeid(bool) && is_enabled_key(def.m_key); - if (!is_enabled_toggle && !setting_is_described(guid, def.m_section, def.m_key) && !entry_has_description(e)) + const bool desc_exempt = is_enabled_toggle && mod_declares_settings(guid); + if (!desc_exempt && !setting_is_described(guid, def.m_section, def.m_key) && !entry_has_description(e)) { continue; } @@ -4066,10 +4082,19 @@ namespace big::mod_settings { for (auto& [key, entry] : cfg->m_entries) { - if (entry && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) + if (!entry || key.m_section != root_section || entry->type() != typeid(bool) || !is_enabled_key(key.m_key)) { - return entry->get_value_base(); + continue; + } + // A stale toggle the menu does not show must not decide whether the mod counts as disabled, + // otherwise every row greys out with no way to recover. + if (!setting_is_declared(guid, key.m_section, key.m_key) + || (!mod_declares_settings(guid) && !setting_is_described(guid, key.m_section, key.m_key) + && !entry_has_description(entry.get()))) + { + continue; } + return entry->get_value_base(); } } return true; diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index adc136d..45a17a8 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -175,6 +175,13 @@ namespace big::mod_settings std::optional get_setting_default(const std::string& guid, const std::string& section, const std::string& key); + // False for a key left over in the .cfg that the mod no longer declares. Always true for Chalk mods, which + // re-bind the whole file and so cannot distinguish stale keys. + bool setting_is_declared(const std::string& guid, const std::string& section, const std::string& key); + + // True while the mod loaded its settings through mod_settings.load rather than Chalk. + bool mod_declares_settings(const std::string& guid); + bool mod_opted_out(const std::string& guid); localized_text mod_opt_out_description(const std::string& guid); From 3e5be869044cc9af940ad442c3db07673ede7e33 Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:02:56 +0200 Subject: [PATCH 088/100] Don't resolve internal IDs in the mod menu --- src/hades2/mod_settings/mod_settings.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 7c14fe5..6162b91 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -4824,7 +4824,9 @@ namespace big::mod_settings // Required helpers. The button ctor anchors later RVA fallbacks. const auto anchor = require("sgg::GUIComponentButton::GUIComponentButton"); g_button_ctor = anchor.as_func(); - g_set_label = require("sgg::GUIComponentButton::SetDisplayName").as_func(); + // SetText, not SetDisplayName: the latter runs the string through GameDataManager::GetTextData and swaps in + // that entry's display name, so e.g. "Random" would render as "Fates' Whim". + g_set_label = require("sgg::GUIComponentButton::SetText").as_func(); g_apply_data = require("sgg::MenuScreen::ApplyDataToComponent").as_func(); g_update_scroll = require("sgg::MiscSettingsScreen::UpdateScrollState").as_func(); g_set_animation = require("sgg::GUIComponentButton::SetAnimation").as_func(); From 1d3a390f2974605c48299c1f5fd61c22f98887dc Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:23:59 +0200 Subject: [PATCH 089/100] Fixed array support --- src/hades2/mod_settings/config_api.cpp | 48 +++++++++++++++----- src/hades2/mod_settings/mod_settings.cpp | 57 +++++++++++++++++++++--- 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 95c334a..ece6643 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -815,12 +815,26 @@ namespace big::mod_settings } for (const auto& [k, v] : config_tbl) { - if (k.get_type() != sol::type::string || !v.is()) + if (!v.is()) { continue; } - const sol::object child_desc = desc_obj.is() ? desc_obj.as()[k] : sol::object(sol::lua_nil); - collect_virtual_rows(guid, v.as(), child_desc, section + "." + k.as(), out); + // Array elements are bound under their stringified index, so recurse into those sections too. + std::string child_key; + if (k.get_type() == sol::type::string) + { + child_key = k.as(); + } + else if (k.get_type() == sol::type::number) + { + child_key = std::to_string(k.as()); + } + else + { + continue; + } + const sol::object child_desc = desc_obj.is() ? desc_obj.as()[child_key] : sol::object(sol::lua_nil); + collect_virtual_rows(guid, v.as(), child_desc, section + "." + child_key, out); } } @@ -1265,14 +1279,8 @@ namespace big::mod_settings desc_tbl = desc_obj.as(); } - for (const auto& [key_obj, value_obj] : defaults) + auto bind_one = [&](const std::string& key, const sol::object& value_obj) { - if (key_obj.get_type() != sol::type::string) - { - continue; - } - const std::string key = key_obj.as(); - sol::object desc = sol::lua_nil; if (has_desc) { @@ -1300,7 +1308,7 @@ namespace big::mod_settings bound_entry = cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); default_any = std::any(value_obj.as()); break; - default: continue; + default: return; } // Capture the serialized default for Reset. @@ -1328,6 +1336,24 @@ namespace big::mod_settings } } } + }; + + for (std::size_t i = 1;; ++i) + { + sol::object v = defaults[i]; + if (!v.valid() || v.get_type() == sol::type::lua_nil) + { + break; + } + bind_one(std::to_string(i), v); + } + + for (const auto& [key_obj, value_obj] : defaults) + { + if (key_obj.get_type() == sol::type::string) + { + bind_one(key_obj.as(), value_obj); + } } } diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 6162b91..050a517 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -509,11 +509,52 @@ namespace big::mod_settings static int compare_display_names(const std::string& a, const std::string& b) { - const std::size_t n = std::min(a.size(), b.size()); - for (std::size_t i = 0; i < n; ++i) + std::size_t i = 0; + std::size_t j = 0; + while (i < a.size() && j < b.size()) { - unsigned char ca = static_cast(a[i]); - unsigned char cb = static_cast(b[i]); + const unsigned char ra = static_cast(a[i]); + const unsigned char rb = static_cast(b[j]); + if (ra >= '0' && ra <= '9' && rb >= '0' && rb <= '9') + { + std::size_t ea = i; + while (ea < a.size() && a[ea] >= '0' && a[ea] <= '9') + { + ++ea; + } + std::size_t eb = j; + while (eb < b.size() && b[eb] >= '0' && b[eb] <= '9') + { + ++eb; + } + std::size_t sa = i; + while (sa + 1 < ea && a[sa] == '0') + { + ++sa; + } + std::size_t sb = j; + while (sb + 1 < eb && b[sb] == '0') + { + ++sb; + } + const std::size_t la = ea - sa; + const std::size_t lb = eb - sb; + if (la != lb) + { + return la < lb ? -1 : 1; + } + const int cmp = a.compare(sa, la, b, sb, lb); + if (cmp != 0) + { + return cmp < 0 ? -1 : 1; + } + i = ea; + j = eb; + continue; + } + + unsigned char ca = ra; + unsigned char cb = rb; if (ca >= 'A' && ca <= 'Z') { ca = static_cast(ca + ('a' - 'A')); @@ -526,12 +567,16 @@ namespace big::mod_settings { return ca < cb ? -1 : 1; } + ++i; + ++j; } - if (a.size() == b.size()) + const std::size_t ra = a.size() - i; + const std::size_t rb = b.size() - j; + if (ra == rb) { return 0; } - return a.size() < b.size() ? -1 : 1; + return ra < rb ? -1 : 1; } static float glyph_weight(unsigned char c) From 375a3dfc4e190f4cd08ea063e9dd27479fc0753f Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:30:53 +0200 Subject: [PATCH 090/100] Support config arrays --- src/hades2/mod_settings/config_api.cpp | 107 ++++++++++++++++++++----- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index ece6643..237562c 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -527,6 +527,29 @@ namespace big::mod_settings return descs.as()[guid]; } + // Internal keys are always the stringified form, while configDesc mirrors config's shape, so a numeric segment + // has to be tried as a number too. + static sol::object desc_child(const sol::table& node, const std::string& part) + { + sol::object child = node[part]; + if (child.valid() && child.get_type() != sol::type::lua_nil) + { + return child; + } + if (part.empty()) + { + return sol::lua_nil; + } + for (const char c : part) + { + if (c < '0' || c > '9') + { + return sol::lua_nil; + } + } + return node[std::stoll(part)]; + } + // configDesc mirrors the config table under the "config" root. static sol::object navigate_description(const sol::object& root, const std::string& section, const std::string& key) { @@ -547,7 +570,7 @@ namespace big::mod_settings { const std::size_t dot = rel.find('.', pos); const std::string part = rel.substr(pos, dot == std::string::npos ? std::string::npos : dot - pos); - sol::object child = node[part]; + sol::object child = desc_child(node, part); if (!child.is()) { return sol::lua_nil; @@ -559,7 +582,7 @@ namespace big::mod_settings } pos = dot + 1; } - return node[key]; + return desc_child(node, key); } // Avoids ReturnOfModding's traceback logging and error tally. @@ -646,7 +669,21 @@ namespace big::mod_settings sol::table desc = desc_obj.as(); for (const auto& [k, v] : desc) { - if (k.get_type() != sol::type::string || !v.is()) + if (!v.is()) + { + continue; + } + // configDesc may be written array-shaped, mirroring an array in config. + std::string desc_key; + if (k.get_type() == sol::type::string) + { + desc_key = k.as(); + } + else if (k.get_type() == sol::type::number) + { + desc_key = std::to_string(k.as()); + } + else { continue; } @@ -657,7 +694,7 @@ namespace big::mod_settings } action_info a; a.section = section; - a.key = k.as(); + a.key = desc_key; read_action_fields(entry, a); for (const char* field : {"displayName", "description", "disabledDescription", "order", "disabled"}) { @@ -672,12 +709,26 @@ namespace big::mod_settings } for (const auto& [k, v] : config_tbl) { - if (k.get_type() != sol::type::string || !v.is()) + if (!v.is()) + { + continue; + } + // Array elements are bound under their stringified index, so recurse into those sections too. + std::string child_key; + if (k.get_type() == sol::type::string) + { + child_key = k.as(); + } + else if (k.get_type() == sol::type::number) + { + child_key = std::to_string(k.as()); + } + else { continue; } - const sol::object child_desc = desc_obj.is() ? desc_obj.as()[k] : sol::object(sol::lua_nil); - collect_actions(v.as(), child_desc, section + "." + k.as(), out); + const sol::object child_desc = desc_obj.is() ? desc_obj.as()[child_key] : sol::object(sol::lua_nil); + collect_actions(v.as(), child_desc, section + "." + child_key, out); } } @@ -722,18 +773,28 @@ namespace big::mod_settings sol::table desc = desc_obj.as(); for (const auto& [k, v] : desc) { - if (k.get_type() != sol::type::string) + // configDesc may be written array-shaped, mirroring an array in config. + std::string key; + if (k.get_type() == sol::type::string) + { + key = k.as(); + } + else if (k.get_type() == sol::type::number) + { + key = std::to_string(k.as()); + } + else { continue; } - const std::string key = k.as(); if (is_reserved_desc_field(key)) { continue; } const std::string path = section + "." + key; - const sol::object cfg_val = config_tbl[key]; + // Indexed with the original key, so configDesc mirrors whatever shape config uses. + const sol::object cfg_val = config_tbl[k]; const bool has_config = cfg_val.valid() && cfg_val.get_type() != sol::type::lua_nil; if (v.get_type() == sol::type::string) { @@ -1014,10 +1075,23 @@ namespace big::mod_settings auto* cf = file(); if (auto* entry = find_entry(cf, section, key)) { + // Lua removes a key when it is assigned nil, and table.remove relies on that to shrink an array. + // Without it a config array could grow but never shrink, leaving stray keys in the .cfg. + if (value.get_type() == sol::type::lua_nil || value.get_type() == sol::type::none) + { + toml_v2::config_definition def(section, key); + cf->remove(def); + return; + } entry_set(entry, value); return; } + if (value.get_type() == sol::type::lua_nil || value.get_type() == sol::type::none) + { + return; + } + // Chalk binds an unknown key on assignment instead of dropping it, and does so recursively for tables. const std::string child = section + "." + key; if (value.is()) @@ -1279,13 +1353,8 @@ namespace big::mod_settings desc_tbl = desc_obj.as(); } - auto bind_one = [&](const std::string& key, const sol::object& value_obj) + auto bind_one = [&](const std::string& key, const sol::object& value_obj, const sol::object& desc) { - sol::object desc = sol::lua_nil; - if (has_desc) - { - desc = desc_tbl[key]; - } const bool described = desc.get_type() != sol::type::lua_nil && desc.get_type() != sol::type::none; const sol::type vt = value_obj.get_type(); @@ -1338,6 +1407,7 @@ namespace big::mod_settings } }; + // The array part first, described by the matching array entry in configDesc, then the string keys. for (std::size_t i = 1;; ++i) { sol::object v = defaults[i]; @@ -1345,14 +1415,15 @@ namespace big::mod_settings { break; } - bind_one(std::to_string(i), v); + bind_one(std::to_string(i), v, has_desc ? sol::object(desc_tbl[i]) : sol::object(sol::lua_nil)); } for (const auto& [key_obj, value_obj] : defaults) { if (key_obj.get_type() == sol::type::string) { - bind_one(key_obj.as(), value_obj); + const std::string key = key_obj.as(); + bind_one(key, value_obj, has_desc ? sol::object(desc_tbl[key]) : sol::object(sol::lua_nil)); } } } From bc7755a4e94e302ebb9b6acfdfb103c21cbea754 Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:59:48 +0200 Subject: [PATCH 091/100] Show mod rows without any config options as greyed out --- src/hades2/mod_settings/config_api.cpp | 23 ++++++++++ src/hades2/mod_settings/mod_settings.cpp | 53 +++++++++++++++++++++--- src/hades2/mod_settings/mod_settings.hpp | 3 ++ 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 237562c..027bfdc 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -104,6 +104,29 @@ namespace big::mod_settings return g_described_keys.contains(metadata_key(guid, section, key)); } + // True while the mod declared anything at all in its configDesc: a described key, an action, or a virtual row. + bool mod_has_described_content(const std::string& guid) + { + std::scoped_lock lock(g_metadata_mutex); + const std::string prefix = guid + '\0'; + for (const auto& k : g_described_keys) + { + if (k.rfind(prefix, 0) == 0) + { + return true; + } + } + if (const auto it = g_actions.find(guid); it != g_actions.end() && !it->second.empty()) + { + return true; + } + if (const auto it = g_virtual_rows.find(guid); it != g_virtual_rows.end() && !it->second.empty()) + { + return true; + } + return false; + } + // True while the mod loaded its settings through mod_settings.load rather than Chalk. bool mod_declares_settings(const std::string& guid) { diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 050a517..5f23a3a 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1522,6 +1522,45 @@ namespace big::mod_settings g_rows.clear(); } + static std::string no_settings_note() + { + return "No config options found for this mod. If you expected there to be any, check with the mod author to ensure they are set up correctly, or check the .cfg file manually."; + } + + static bool is_enabled_key(const std::string& key); + static bool entry_has_description(const toml_v2::config_file::config_entry_base* entry); + + static bool mod_has_settings(const std::string& stem) + { + if (mod_has_described_content(stem)) + { + return true; + } + auto* cfg = live_config_file(stem); + if (!cfg) + { + return false; + } + // Chalk mods keep their descriptions on the config entry rather than in a configDesc. + const bool declares = mod_declares_settings(stem); + for (auto& [key, entry] : cfg->m_entries) + { + if (!entry || key.m_key == section_empty_key) + { + continue; + } + if (entry_has_description(entry.get())) + { + return true; + } + if (declares && key.m_section == root_section && entry->type() == typeid(bool) && is_enabled_key(key.m_key)) + { + return true; + } + } + return false; + } + static void build_mod_list(MiscSettingsScreen* screen) { std::vector stems; @@ -1557,13 +1596,17 @@ namespace big::mod_settings for (const auto& [display, stem] : mods) { - // Opted-out mods stay listed but cannot be opened. - const bool opted_out = mod_opted_out(stem); - if (auto* row = make_text_row(screen, escape_markup(display).c_str(), opted_out, /*block_input*/ false)) + // Opted-out mods and mods with nothing to show stay listed but cannot be opened. + const bool opted_out = mod_opted_out(stem); + const bool no_settings = !opted_out && !mod_has_settings(stem); + const bool unopenable = opted_out || no_settings; + if (auto* row = make_text_row(screen, escape_markup(display).c_str(), unopenable, /*block_input*/ false)) { PanelRow pr{row, RowKind::mod_entry, stem, {}}; - pr.disabled = opted_out; - pr.description = opted_out ? opt_out_description(stem) : mod_description_from_stem(stem); + pr.disabled = unopenable; + pr.description = opted_out ? opt_out_description(stem) + : no_settings ? no_settings_note() + : mod_description_from_stem(stem); g_rows.push_back(std::move(pr)); } } diff --git a/src/hades2/mod_settings/mod_settings.hpp b/src/hades2/mod_settings/mod_settings.hpp index 45a17a8..eb570ad 100644 --- a/src/hades2/mod_settings/mod_settings.hpp +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -182,6 +182,9 @@ namespace big::mod_settings // True while the mod loaded its settings through mod_settings.load rather than Chalk. bool mod_declares_settings(const std::string& guid); + // True while the mod declared a described key, an action or a virtual row. + bool mod_has_described_content(const std::string& guid); + bool mod_opted_out(const std::string& guid); localized_text mod_opt_out_description(const std::string& guid); From d98871b00c65f04616cf22a4353a7c229482fddc Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:00:34 +0200 Subject: [PATCH 092/100] Prevent crash if configDesc is present but empty --- src/hades2/mod_settings/config_api.cpp | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/hades2/mod_settings/config_api.cpp b/src/hades2/mod_settings/config_api.cpp index 027bfdc..ab35647 100644 --- a/src/hades2/mod_settings/config_api.cpp +++ b/src/hades2/mod_settings/config_api.cpp @@ -685,6 +685,11 @@ namespace big::mod_settings } // Collects action buttons from configDesc, guided by config defaults. + static sol::object nil_object(lua_State* L) + { + return sol::make_object(L, sol::lua_nil); + } + static void collect_actions(const sol::table& config_tbl, const sol::object& desc_obj, const std::string& section, std::vector& out) { if (desc_obj.is()) @@ -750,7 +755,7 @@ namespace big::mod_settings { continue; } - const sol::object child_desc = desc_obj.is() ? desc_obj.as()[child_key] : sol::object(sol::lua_nil); + const sol::object child_desc = desc_obj.is() ? sol::object(desc_obj.as()[child_key]) : nil_object(config_tbl.lua_state()); collect_actions(v.as(), child_desc, section + "." + child_key, out); } } @@ -917,7 +922,7 @@ namespace big::mod_settings { continue; } - const sol::object child_desc = desc_obj.is() ? desc_obj.as()[child_key] : sol::object(sol::lua_nil); + const sol::object child_desc = desc_obj.is() ? sol::object(desc_obj.as()[child_key]) : nil_object(config_tbl.lua_state()); collect_virtual_rows(guid, v.as(), child_desc, section + "." + child_key, out); } } @@ -1375,6 +1380,8 @@ namespace big::mod_settings { desc_tbl = desc_obj.as(); } + // A mod may have no configDesc at all, so the "no description" value must still carry the Lua state. + const sol::object nil_desc = nil_object(defaults.lua_state()); auto bind_one = [&](const std::string& key, const sol::object& value_obj, const sol::object& desc) { @@ -1438,7 +1445,7 @@ namespace big::mod_settings { break; } - bind_one(std::to_string(i), v, has_desc ? sol::object(desc_tbl[i]) : sol::object(sol::lua_nil)); + bind_one(std::to_string(i), v, has_desc ? sol::object(desc_tbl[i]) : nil_desc); } for (const auto& [key_obj, value_obj] : defaults) @@ -1446,7 +1453,7 @@ namespace big::mod_settings if (key_obj.get_type() == sol::type::string) { const std::string key = key_obj.as(); - bind_one(key, value_obj, has_desc ? sol::object(desc_tbl[key]) : sol::object(sol::lua_nil)); + bind_one(key, value_obj, has_desc ? sol::object(desc_tbl[key]) : nil_desc); } } } From 1754d60f865eec32c570ed7e17431c1bb421aa88 Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:02:15 +0200 Subject: [PATCH 093/100] Updated description --- src/hades2/mod_settings/mod_settings.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 5f23a3a..f6da033 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -1524,7 +1524,7 @@ namespace big::mod_settings static std::string no_settings_note() { - return "No config options found for this mod. If you expected there to be any, check with the mod author to ensure they are set up correctly, or check the .cfg file manually."; + return "No described config options found for this mod. If you expected there to be any, check with the mod author to ensure they are set up correctly, or check the .cfg file manually."; } static bool is_enabled_key(const std::string& key); From e3289c8d56ca1aea62e747a21ed5cc3453415691 Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:16:38 +0200 Subject: [PATCH 094/100] Added centered mod title on main options page per mod --- src/hades2/mod_settings/mod_settings.cpp | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index f6da033..23ec55e 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -303,6 +303,7 @@ namespace big::mod_settings static constexpr float row_location_x = 1560.0f; // component X (right pane), like OptionToggleButton static constexpr float row_text_offset_x = -900.0f; // left-justify the label to the option-name column static constexpr float value_text_offset_x = 15.0f; // right-justify the value, aligning it with the toggle column + static constexpr float row_center_offset_x = (row_text_offset_x + value_text_offset_x) * 0.5f; static constexpr float numbox_location_x = 1365.0f; // native OptionNumBox X (box + arrows clear the scrollbar) static constexpr float slider_location_x = 1330.0f; // native OptionSlider X (bar + value clear the scrollbar) static constexpr float button_center_x = 1130.0f; // centered action button X (clear of the scrollbar) @@ -908,7 +909,7 @@ namespace big::mod_settings } } - static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true, bool no_hover_highlight = false) + static GUIComponent* make_text_row(MiscSettingsScreen* screen, const char* label, bool disabled = false, bool block_input = true, bool no_hover_highlight = false, bool centered = false) { auto* row = create_button(screen); if (!row) @@ -928,8 +929,8 @@ namespace big::mod_settings *reinterpret_cast(def + def_alternate_graphic) = 0; *reinterpret_cast(def + def_width) = 0.0f; *reinterpret_cast(def + def_height) = 0.0f; - *reinterpret_cast(def + def_text_justification) = 0; - *reinterpret_cast(def + def_text_offset_x) = row_text_offset_x; + *reinterpret_cast(def + def_text_justification) = centered ? 2 : 0; // sgg::Justification CENTER / LEFT + *reinterpret_cast(def + def_text_offset_x) = centered ? row_center_offset_x : row_text_offset_x; *reinterpret_cast(def + def_y) = row_base_y; *reinterpret_cast(def + def_spacing) = row_pitch; @@ -2665,6 +2666,19 @@ namespace big::mod_settings const bool mod_enabled = contents.mod_enabled; toml_v2::config_file* const view_cfg = contents.view_cfg; + // Add the mod title as a centered read-only row to the main page of each mod + if (section == root_section) + { + const std::string title = escape_markup(display_name_from_stem(stem)); + if (auto* row = make_text_row(screen, title.c_str(), /*disabled*/ false, /*block_input*/ false, /*no_hover_highlight*/ true, /*centered*/ true)) + { + PanelRow pr{row, RowKind::info, stem, {}}; + pr.disabled = true; + pr.description = mod_description_from_stem(stem); + g_rows.push_back(std::move(pr)); + } + } + for (const auto& it : contents.items) { const bool is_enabled_row = it.is_enabled; From a2d82ba5db071a34140a4137eb4109ed647173ad Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:41:39 +0200 Subject: [PATCH 095/100] Render config values verbatim in the mod menu --- src/hades2/mod_settings/mod_settings.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 23ec55e..eaaefb9 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -65,6 +65,7 @@ namespace big::mod_settings static constexpr std::size_t def_green = 0xF0; // mGreen button tint (float) static constexpr std::size_t def_blue = 0xF4; // mBlue button tint (float) static constexpr std::size_t def_text_justification = 0xEA; // mTextJustification (sgg::Justification: LEFT=0) + static constexpr std::size_t def_parse_text_markup = 0x16; static constexpr std::size_t def_text_red = 0x1'0C; // mTextRed (float) static constexpr std::size_t def_text_green = 0x1'10; // mTextGreen (float) static constexpr std::size_t def_text_blue = 0x1'14; // mTextBlue (float) @@ -1109,6 +1110,18 @@ namespace big::mod_settings return row; } + static void disable_text_markup(GUIComponent* button) + { + if (!button) + { + return; + } + if (auto* label_box = *reinterpret_cast(reinterpret_cast(button) + button_label_offset)) + { + *reinterpret_cast(label_box + component_def_offset + def_parse_text_markup) = 0; + } + } + static GUIComponent* make_value_display(MiscSettingsScreen* screen, const char* text, bool disabled) { auto* row = create_button(screen); @@ -1159,6 +1172,7 @@ namespace big::mod_settings { g_set_animation(row, g_blank_graphic); } + disable_text_markup(row); if (g_set_label) { g_set_label(row, text); From f2fdccfb4836eb497ed4d53b175bea417bebf124 Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:50:49 +0200 Subject: [PATCH 096/100] Updated schema --- docs/mod_settings/config_schema.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mod_settings/config_schema.lua b/docs/mod_settings/config_schema.lua index 0509e2b..104d20e 100644 --- a/docs/mod_settings/config_schema.lua +++ b/docs/mod_settings/config_schema.lua @@ -193,4 +193,4 @@ --- Only keys with a `configDesc` entry are shown in the menu: a `config` key with no entry here is treated as --- internal state and hidden. The mod's master `enabled` toggle is always shown regardless, so the mod stays --- toggleable. ----@alias mod_settings.config_desc table +---@alias mod_settings.config_desc table From bf573563aa80cabf57f44a27a2342ba4ed2f28df Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:50:26 +0200 Subject: [PATCH 097/100] Use Enter instead of Spacebar icon for submit button hint --- src/hades2/mod_settings/mod_settings.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index eaaefb9..36144a3 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -3439,7 +3439,7 @@ namespace big::mod_settings std::string confirm; if (g_editing) { - confirm = "{SL} SUBMIT"; + confirm = "{CF} SUBMIT"; } else if (PanelRow* row = find_row(active_row_component(screen))) { From 06c34ae55789e9a661ceec7dfa911b6c7b72bc6f Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:51:31 +0200 Subject: [PATCH 098/100] Ensure mouse pointer doesn't disappear when typing wasd in freetext fields --- src/hades2/mod_settings/mod_settings.cpp | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 36144a3..758bc15 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -276,7 +276,7 @@ namespace big::mod_settings static std::uintptr_t g_button_vtable_patched = 0; static teleport_cursor_fn g_teleport_cursor = nullptr; static set_mouse_over_fn g_set_mouse_over = nullptr; - static const bool* g_use_mouse = nullptr; + static bool* g_use_mouse = nullptr; static const char* g_config_language = nullptr; static component_focused_fn g_component_focused = nullptr; @@ -448,6 +448,8 @@ namespace big::mod_settings static bool g_edit_numeric = false; static bool g_edit_confirm = false; static bool g_edit_cancel = false; + // True while an edit began with the mouse pointer in use, so it can be kept visible while typing. + static bool g_edit_had_mouse = false; static std::string key_to_display(const std::string& key); @@ -1859,6 +1861,9 @@ namespace big::mod_settings g_edit_numeric = entry && entry->type() != typeid(std::string); g_edit_confirm = false; g_edit_cancel = false; + // Typing W, A, S or D feeds MenuScreen's directional selection, which clears ConfigOptions::UseMouse and + // hides the pointer. Remember whether the pointer was in use so it can be held for the edit. + g_edit_had_mouse = g_use_mouse && *g_use_mouse; } static void exit_edit_mode() @@ -1867,9 +1872,10 @@ namespace big::mod_settings g_edit_component = nullptr; g_edit_entry = nullptr; g_edit_buffer.clear(); - g_edit_cursor = 0; - g_edit_confirm = false; - g_edit_cancel = false; + g_edit_cursor = 0; + g_edit_confirm = false; + g_edit_cancel = false; + g_edit_had_mouse = false; } static std::string restart_change_key(toml_v2::config_file::config_entry_base* entry, const std::string& stem) @@ -4813,6 +4819,12 @@ namespace big::mod_settings { if (g_editing) { + // Directional selection clears UseMouse when a typed W/A/S/D reaches it, which hides the pointer and, + // if a confirming click's edge is missed, can leave it hidden. Reassert it for the whole edit. + if (g_use_mouse && g_edit_had_mouse) + { + *g_use_mouse = true; + } if (g_was_key_pressed && input) { if (g_was_key_pressed(input, key_return) || g_was_key_pressed(input, key_kp_enter)) @@ -4990,7 +5002,7 @@ namespace big::mod_settings g_active_profile = big::hades2_symbol_to_address["sgg::ProfileManager::ACTIVE_PROFILE"].as(); // Named PDB data symbols move with .data and .rdata, unlike anchor-relative RVAs. - g_use_mouse = big::hades2_symbol_to_address["sgg::ConfigOptions::UseMouse"].as(); + g_use_mouse = big::hades2_symbol_to_address["sgg::ConfigOptions::UseMouse"].as(); g_config_language = big::hades2_symbol_to_address["sgg::ConfigOptions::Language"].as(); g_controls_cancel = big::hades2_symbol_to_address["sgg::Controls::Cancel"].as(); g_controls_select = big::hades2_symbol_to_address["sgg::Controls::Select"].as(); From 2fbd66c57cf8fa0f8c7fe7b4a891fbf53b9b5dd3 Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:37:48 +0200 Subject: [PATCH 099/100] Discard stale enum commits from the settings menu --- src/hades2/mod_settings/mod_settings.cpp | 35 +++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 758bc15..33729c9 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -369,6 +369,8 @@ namespace big::mod_settings std::vector enum_values; std::vector enum_labels; + int enum_index = -1; + std::string target_section; // Real config section for virtual-row Lua I/O, which may differ from the view path. @@ -421,6 +423,9 @@ namespace big::mod_settings static constexpr int keep_active_frame_count = 3; static int g_keep_active_frames = 0; + static constexpr int commit_guard_frame_count = 2; + static int g_commit_guard_frames = 0; + static constexpr float dynamic_refresh_settle_seconds = 0.15f; // Sliders fire every frame while dragged, so rebuild only after a quiet gap. @@ -2946,6 +2951,7 @@ namespace big::mod_settings pr.is_enum = true; pr.enum_values = std::move(enum_values); pr.enum_labels = std::move(enum_labels); + pr.enum_index = enum_index; } else if (is_stepper) { @@ -3157,6 +3163,7 @@ namespace big::mod_settings pr.is_enum = true; pr.enum_values = enum_values; pr.enum_labels = enum_labels; + pr.enum_index = enum_index; } else if (ro_is_toggle) { @@ -3229,6 +3236,7 @@ namespace big::mod_settings pr.is_enum = true; pr.enum_values = std::move(enum_values); pr.enum_labels = std::move(enum_labels); + pr.enum_index = enum_index; } else if (built_slider || built_stepper) { @@ -3896,6 +3904,8 @@ namespace big::mod_settings { g_has_pending_restore = false; } + + g_commit_guard_frames = commit_guard_frame_count; } static void apply_nav(MiscSettingsScreen* screen) @@ -4347,12 +4357,30 @@ namespace big::mod_settings if (row->is_enum) { - int idx = static_cast(*reinterpret_cast(reinterpret_cast(self) + numbox_value_offset)); + int idx = static_cast(std::lroundf(*reinterpret_cast(reinterpret_cast(self) + numbox_value_offset))); if (idx < 0 || idx >= static_cast(row->enum_values.size())) { return; } set_numbox_value_text(reinterpret_cast(self), row->enum_labels[idx].c_str()); + + if (idx == row->enum_index) + { + return; + } + + // Within the guard window this move came from input resolved against the old layout, so put the box back. + if (g_commit_guard_frames > 0 && row->enum_index >= 0 && row->enum_index < static_cast(row->enum_values.size())) + { + if (g_numbox_set_value) + { + g_numbox_set_value(self, static_cast(row->enum_index), false); + } + set_numbox_value_text(reinterpret_cast(self), row->enum_labels[row->enum_index].c_str()); + return; + } + + row->enum_index = idx; commit_row_serialized(row, row->enum_values[idx], row->enum_labels[idx]); return; } @@ -4793,6 +4821,11 @@ namespace big::mod_settings } } + if (g_commit_guard_frames > 0) + { + --g_commit_guard_frames; + } + void* result = big::g_hooking->get_original()(self, dt, input); if (on_mods_tab) From 5eaf987b5f87d73b9e49236ec9b9114619fc5dc1 Mon Sep 17 00:00:00 2001 From: Nikkel Mollenhauer <57323886+NikkelM@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:53:15 +0200 Subject: [PATCH 100/100] Refresh the panel after onChanged edits other settings --- src/hades2/mod_settings/mod_settings.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/hades2/mod_settings/mod_settings.cpp b/src/hades2/mod_settings/mod_settings.cpp index 33729c9..fa2f613 100644 --- a/src/hades2/mod_settings/mod_settings.cpp +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -2005,6 +2005,11 @@ namespace big::mod_settings return !row->config_section.empty() ? row->config_section : g_view_section; } + static bool commit_may_change_other_rows(const PanelRow* row) + { + return g_view_has_dynamic || row->is_virtual_input || (row->entry && static_cast(row->entry->m_setting_changed)); + } + static bool commit_row_bool(PanelRow* row, bool v) { bool changed = false; @@ -2030,7 +2035,7 @@ namespace big::mod_settings changed = true; } } - if (changed && g_view_has_dynamic) + if (changed && commit_may_change_other_rows(row)) { g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; } @@ -2062,7 +2067,7 @@ namespace big::mod_settings changed = true; } } - if (changed && g_view_has_dynamic) + if (changed && commit_may_change_other_rows(row)) { g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; } @@ -2095,7 +2100,7 @@ namespace big::mod_settings changed = true; } } - if (changed && g_view_has_dynamic) + if (changed && commit_may_change_other_rows(row)) { g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; } @@ -2162,7 +2167,7 @@ namespace big::mod_settings // Reflect the committed value in the right-hand display in place. refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); - if (g_view_has_dynamic) + if (g_view_has_dynamic || static_cast(g_edit_entry->m_setting_changed)) { g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; } @@ -4622,7 +4627,7 @@ namespace big::mod_settings note_change_if_restart_required(entry, new_value ? "on" : "off"); // Toggling bools can change greying or dynamic rows, so rebuild in place. - if (matched_row.is_enabled_toggle || g_view_has_dynamic) + if (matched_row.is_enabled_toggle || commit_may_change_other_rows(&matched_row)) { g_pending_view = View::mod_settings; g_pending_stem = matched_row.stem;