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..5e192e7 --- /dev/null +++ b/docs/lua/tables/definitions/rom.mod_settings.lua @@ -0,0 +1,16 @@ +---@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. 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. 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 new file mode 100644 index 0000000..615c4f1 --- /dev/null +++ b/docs/lua/tables/rom.mod_settings.md @@ -0,0 +1,33 @@ +# Table: rom.mod_settings + +## Functions (2) + +### `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. 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:** + - `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. + +**Example Usage:** +```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. 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 note when the mod's disabled row is hovered. + +**Example Usage:** +```lua +rom.mod_settings.opt_out("Please use the imgui menu to configure this mod (opens with \"Insert\" by default).") +``` diff --git a/docs/mod_settings/README.md b/docs/mod_settings/README.md new file mode 100644 index 0000000..3fb4d3e --- /dev/null +++ b/docs/mod_settings/README.md @@ -0,0 +1,193 @@ +# 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: + +- `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. + +## VS Code type hints + +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` from this repository 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 +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 | +| --- | --- | --- | +| `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 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. | +| `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 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. | +| `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 +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`, `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. + +## 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 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, 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", +}, +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 config.easy_mode end, + disabledDescription = "Enable \"Easy Mode\" above to change this.", +}, +``` + +## 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`. + +```lua +apply_scaling = { + 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.", +}, +``` + +## 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 freetext field), + 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 +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. +- **`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 +-- Not chosen yet, so get() returns nil until the player picks one +mod.EasyModePreset = nil +local configDesc = { + preset = { + 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, + }, +} +``` + +## 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 fields. +It receives the setting's key and the new value: + +```lua +local configDesc = { + run_difficulty = { + displayName = "Run difficulty", + min = 0, max = 100, + onChanged = function(key, newValue) + if game.CurrentRun then + mod.ApplyNewRunDifficulty(newValue) + 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. Note: + +- 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. + +## Localization tables + +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 currently set 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..104d20e --- /dev/null +++ b/docs/mod_settings/config_schema.lua @@ -0,0 +1,196 @@ +---@meta + +--- A user-facing string. Either a plain string, or a localization 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). 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 + +--- 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 + +--- 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 where +--- 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.dynamic_string +--- Help text shown while the category's row is highlighted. +---@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 + + +--- 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 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. +---@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? mod_settings.dynamic_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 +--- 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. Defaults to the normal `description` when omitted. +---@field disabledDescription? mod_settings.dynamic_string +--- 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. 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" +--- 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. +--- 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) +--- 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 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. +---@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. +---@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 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, lowest first. +---@field order? mod_settings.dynamic_number +--- 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 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 +--- 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, +--- 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 (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 +--- 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) +--- 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'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 +--- 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 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. +---@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 +--- 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. 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 new file mode 100644 index 0000000..ab35647 --- /dev/null +++ b/src/hades2/mod_settings/config_api.cpp @@ -0,0 +1,2060 @@ +#include "mod_settings.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +using namespace al; +// clang-format on +#undef ERROR + +namespace big::mod_settings +{ +#pragma region Metadata registries and accessors + + static std::mutex g_metadata_mutex; + static std::map g_setting_metadata; + + static std::map g_setting_default; + + static std::set g_described_keys; + + static std::map g_opted_out_mods; + + static std::map> g_actions; + + static std::map> g_virtual_rows; + + // 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) + { + 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; + } + + static void clear_metadata_for(const std::string& guid) + { + const std::string prefix = guid + '\0'; + for (auto it = g_setting_metadata.begin(); it != g_setting_metadata.end();) + { + it = (it->first.rfind(prefix, 0) == 0) ? g_setting_metadata.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); + } + 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) + { + std::scoped_lock lock(g_metadata_mutex); + 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; + } + + 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)); + } + + // 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) + { + 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); + const auto it = g_setting_default.find(metadata_key(guid, section, key)); + if (it == g_setting_default.end()) + { + return std::nullopt; + } + 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; + } + + 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{}; + } + + 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{}; + } + +#pragma endregion + +#pragma region Config.lua parsing helpers + + static std::string serialize_option(const sol::object& v); + static editable_context parse_editable_context(const sol::object& o, editable_context fallback); + + // 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; + 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; + } + + // Picks one language-independent value for the on-disk .cfg comment. + 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; + } + + // 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) + { + return parse_localized(desc); + } + if (desc.is()) + { + sol::table t = desc.as(); + sol::object as_field = t["description"]; + if (as_field.valid() && as_field != sol::lua_nil) + { + return parse_localized(as_field); + } + sol::object as_first = t[1]; + if (as_first.valid() && as_first != sol::lua_nil) + { + return parse_localized(as_first); + } + } + return {}; + } + + // 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; + 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()); + } + } + } + // '.' is the menu-path separator, so reject ambiguous segments. + 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; + } + + // 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; + 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(); + 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"]); + 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); + + // 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) + { + g.has_dynamic = true; + break; + } + } + g.children = parse_menu_groups(gt["groups"]); + out.push_back(std::move(g)); + }); + return out; + } + + static bool description_requires_restart(const sol::object& desc) + { + if (!desc.is()) + { + return false; + } + sol::object flag = desc.as()["restartRequired"]; + return flag.is() && flag.as(); + } + + // 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) + { + const std::string s = o.as(); + if (s == "mainMenu") + { + return editable_context::main_menu; + } + if (s == "inSave") + { + return editable_context::in_save; + } + if (s == "inHub") + { + return editable_context::in_hub; + } + if (s == "any") + { + return editable_context::any; + } + } + return fallback; + } + + // 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()) + { + 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 ""; + } + } + + 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])); + } + } + +#pragma endregion + +#pragma region Metadata extraction + + // The menu derives widget kind from the value type plus `values`. + static setting_metadata extract_metadata(const sol::table& desc) + { + setting_metadata m; + m.description = describe(desc); + + sol::object display_name = desc["displayName"]; + m.name = parse_localized(display_name); + + m.disabled_description = parse_localized(desc["disabledDescription"]); + + 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); + }); + + 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) + { + m.has_order = true; + m.order = order_field.as(); + } + + sol::object hidden_field = desc["hidden"]; + if (hidden_field.is()) + { + m.hidden = hidden_field.as(); + } + + sol::object disabled_field = desc["disabled"]; + if (disabled_field.is()) + { + m.disabled = disabled_field.as(); + } + + 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["isPercentage"]; + if (is_pct_field.is()) + { + m.is_percentage = is_pct_field.as(); + } + + m.restart_required = description_requires_restart(desc); + + // 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(); + 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` 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); + } + + // The menu forces the master toggle and restartRequired settings to main_menu. + m.context = parse_editable_context(desc["editableContext"], editable_context::any); + + // 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) + { + m.has_dynamic = true; + break; + } + } + + m.group = parse_group(desc["group"]); + + return m; + } + +#pragma endregion + +#pragma region Description navigation and dynamic-field resolution + + // 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]; + 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]; + } + + // 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) + { + if (!root.is()) + { + return sol::lua_nil; + } + sol::table node = root.as(); + + + 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::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 = desc_child(node, part); + if (!child.is()) + { + return sol::lua_nil; + } + node = child.as(); + if (dot == std::string::npos) + { + break; + } + pos = dot + 1; + } + return desc_child(node, key); + } + + // Avoids ReturnOfModding's traceback logging and error tally. + static int silent_error_handler(lua_State* /*L*/) + { + return 1; // keep the error object on the stack. + } + + // 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) + { + 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)...); + } + + 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 = call_mod_callback(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(); + } + + // 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(); + for (const auto& [k, v] : desc) + { + if (k.get_type() != sol::type::string) + { + out[k] = v; + continue; + } + const std::string field = k.as(); + if (field == "onChanged" || field == "action" || field == "get" || field == "set" || field == "text") + { + out[k] = v; + continue; + } + out[k] = evaluate_field(v, guid, field.c_str()); + } + return out; + } + +#pragma endregion + +#pragma region Action and virtual-row collection + + static void read_action_fields(const sol::table& entry, action_info& a) + { + 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; + a.order = o.as(); + } + if (sol::object d = entry["disabled"]; d.is()) + { + a.disabled = d.as(); + } + a.context = parse_editable_context(entry["editableContext"], editable_context::any); + a.group = parse_group(entry["group"]); + } + + // 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()) + { + sol::table desc = desc_obj.as(); + for (const auto& [k, v] : desc) + { + 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; + } + sol::table entry = v.as(); + if (entry["action"].get_type() != sol::type::function) + { + continue; + } + action_info a; + a.section = section; + a.key = desc_key; + read_action_fields(entry, a); + for (const char* field : {"displayName", "description", "disabledDescription", "order", "disabled"}) + { + if (entry[field].get_type() == sol::type::function) + { + a.has_dynamic = true; + break; + } + } + out.push_back(std::move(a)); + } + } + for (const auto& [k, v] : config_tbl) + { + 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() ? sol::object(desc_obj.as()[child_key]) : nil_object(config_tbl.lua_state()); + collect_actions(v.as(), child_desc, section + "." + child_key, out); + } + } + + // 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 = { + "displayName", + "description", + "disabledDescription", + "min", + "max", + "step", + "values", + "labels", + "order", + "hidden", + "disabled", + "restartRequired", + "editableContext", + "showAsPercentage", + "isPercentage", + "onChanged", + "action", + "virtual", + "get", + "set", + "text", + "type", + "default", + "group", // per-entry menu placement. + "groups", // root author group tree. + }; + return reserved.contains(key); + } + + // 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()) + { + sol::table desc = desc_obj.as(); + for (const auto& [k, v] : desc) + { + // 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; + } + if (is_reserved_desc_field(key)) + { + continue; + } + + const std::string path = section + "." + 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) + { + 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) + { + 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; + } + if (!is_virtual) + { + 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; + vr.group = parse_group(entry["group"]); + 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; + 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()) // interactive rows with get/values are dynamic. + { + vr.has_dynamic = vr.has_dynamic || vr.interactive; + } + + if (vr.interactive) + { + 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) + { + 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)); + } + } + for (const auto& [k, v] : config_tbl) + { + 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() ? 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); + } + } + +#pragma endregion + +#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); + } + + // 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) + { + if (def.m_section == section || def.m_section.rfind(prefix, 0) == 0) + { + return true; + } + } + return false; + } + + 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; + } + + 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; + } + } + + // 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()) + { + 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 = call_mod_callback(callback, changed->m_definition.m_key, value); + if (!result.valid()) + { + const sol::error err = result; + LOG(WARNING) << "[mod_settings] onChanged callback failed for " << changed->m_definition.m_section << "." + << changed->m_definition.m_key << ": " << err.what(); + } + }; + } + + // 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()) + { + 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; + } + +#pragma endregion + +#pragma region Config proxy + + // 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"; + + // 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 + // 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 + { + 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 + { + if (key == section_empty_key) + { + return sol::lua_nil; + } + auto* cf = file(); + 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 make_proxy(ts, guid, child); + } + return sol::lua_nil; + } + + 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)) + { + // 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()) + { + const mod_config_proxy child_proxy{guid, child}; + for (const auto& [k, v] : value.as()) + { + if (k.get_type() == sol::type::string) + { + 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; + } + } + + 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; + auto* cf = file(); + if (!cf) + { + return out; + } + 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()); + } + 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, guid, prefix + child); + } + } + } + return out; + } + + // __len returns the highest positive-integer leaf key. + 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; + if (def.m_section == section && parse_positive_index(def.m_key, index) && static_cast(index) > n) + { + n = static_cast(index); + } + } + return n; + } + + // __pairs walks one level 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 is consulted by ipairs on Lua 5.2. + 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(); + auto* cf = file(); + 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 is used by ModUtil's next/qrawpairs. + 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 is used by ModUtil's inext/qrawipairs. + 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(file(), 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)); + } + }; + + 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. + return install_proxy(ts, lua.create_table(), guid, section); + } + + 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]; + const std::string guid = cf_map[wrapper]; + const std::string section = section_map[wrapper]; + return mod_config_proxy{guid, section}; + } + + // Chalk stringifies numeric config keys. + 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); + } + +#pragma endregion + +#pragma region Default binding and config.lua load + + struct collected_metadata + { + std::string section; + std::string key; + setting_metadata meta; + }; + + // 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; + const bool has_desc = desc_obj.is(); + if (has_desc) + { + 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) + { + 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; + 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, described_out); + break; + case sol::type::boolean: + 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: + 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: + bound_entry = cf->bind(section, key, value_obj.as(), localized_fallback(describe(desc))); + default_any = std::any(value_obj.as()); + break; + default: return; + } + + // 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)); + + if (described) + { + described_out.emplace_back(section, key); + } + } + + // Rich description tables carry leaf or group metadata. + if (desc.is()) + { + meta_out.push_back({section, key, extract_metadata(desc.as())}); + + if (bound_entry) + { + sol::object on_changed = desc.as()["onChanged"]; + if (on_changed.is()) + { + attach_on_change(bound_entry, on_changed.as()); + } + } + } + }; + + // 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]; + if (!v.valid() || v.get_type() == sol::type::lua_nil) + { + break; + } + bind_one(std::to_string(i), v, has_desc ? sol::object(desc_tbl[i]) : nil_desc); + } + + for (const auto& [key_obj, value_obj] : defaults) + { + 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]) : nil_desc); + } + } + } + + // 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) + { + 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(); + + // Reuses Chalk's .cfg path. + 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"); + + 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_file_path; + + 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]; + + // 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 + if (defaults.is()) + { + bind_defaults(cf.get(), defaults.as(), descriptions, "config", collected, collected_defaults, collected_described); + } + cf->save(); + + // 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()) + { + descs.as()[guid] = descriptions; + } + } + + std::vector actions; + if (defaults.is()) + { + collect_actions(defaults.as(), descriptions, root_section, actions); + } + + std::vector virtual_rows; + if (defaults.is()) + { + collect_virtual_rows(guid, defaults.as(), descriptions, root_section, virtual_rows); + } + + std::vector menu_groups; + if (descriptions.is()) + { + menu_groups = parse_menu_groups(descriptions.as()["groups"]); + } + + { + std::scoped_lock lock(g_metadata_mutex); + clear_metadata_for(guid); + for (auto& cm : collected) + { + 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); + } + for (const auto& [section, key] : collected_described) + { + g_described_keys.insert(metadata_key(guid, section, key)); + } + 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. + if (defaults.is()) + { + return install_proxy(ts, defaults.as(), guid, "config"); + } + return make_proxy(ts, guid, "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) + { + 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; // resolved to concrete values. + 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; + } + + 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; + } + + // 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) + { + 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; + { + 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 (section.empty() || a.section == section) // empty section means all sections. + { + result.push_back(a); + } + } + } + + // Re-evaluate dynamic fields against the current game state. + 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 = call_mod_callback(fn); + if (!rv.valid()) + { + const sol::error err = rv; + LOG(WARNING) << "[mod_settings] action " << section << "." << key << " for " << guid << " failed: " << err.what(); + } + } + + 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 (section.empty() || vr.section == section) // empty section means all sections. + { + 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(); + + // `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) + { + return text.as(); + } + if (text.get_type() == sol::type::function) + { + sol::protected_function fn = text; + sol::protected_function_result rv = call_mod_callback(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 = call_mod_callback(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 falls back to 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 = 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; + } + if (!rv.valid()) + { + const sol::error err = rv; + LOG(WARNING) << "[mod_settings] virtual row set " << section << "." << key << " for " << guid << " failed: " << err.what(); + } + } + +#pragma endregion + +#pragma region Virtual-row value helpers and reset + + static double parse_serialized_number(const std::string& s) + { + try + { + return std::stod(s); + } + catch (...) + { + return 0.0; + } + } + + // Enum options and strings are both carried as strings. + 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; + } + } + + 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; + } + + // 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") + { + 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_row_to_default(const std::string& guid, const std::string& section, const std::string& key) + { + bool interactive = false; + { + std::scoped_lock lock(g_metadata_mutex); + const auto it = g_virtual_rows.find(guid); + if (it != g_virtual_rows.end()) + { + for (const auto& vr : it->second) + { + if (vr.section == section && vr.key == key) + { + interactive = vr.interactive; // read-only rows have no set() to reset. + break; + } + } + } + } + if (!interactive) + { + return false; + } + + const auto meta = resolve_setting_metadata(guid, section, key); + if (!meta || !meta->has_default) + { + return false; // only rows with `default` reset. + } + + // 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) + { + 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) + { + return false; + } + set_virtual_value(guid, section, key, target); + 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 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) + { + if (!this_env) + { + return; + } + auto* module = big::lua_module::this_from(this_env); + if (!module) + { + 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[module->guid()] = std::move(note); + } + + void bind_config_api(sol::state_view& state, sol::table& lua_ext) + { + // 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(); + g_setting_default.clear(); + g_opted_out_mods.clear(); + g_actions.clear(); + g_virtual_rows.clear(); + g_menu_groups.clear(); + g_described_keys.clear(); + } + + // 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; + 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); + ns.set_function("opt_out", &opt_out); + + // Lua-owned configDesc storage avoids dangling C++ sol references across Lua-state resets. + 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 new file mode 100644 index 0000000..fa2f613 --- /dev/null +++ b/src/hades2/mod_settings/mod_settings.cpp @@ -0,0 +1,5192 @@ +#include "mod_settings.hpp" + +#include "sgg_gui.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// clang-format off +#include +using namespace al; +// clang-format on +#undef ERROR + +namespace big::mod_settings +{ +#pragma region Native screen offsets, RVAs, and constants + + using sgg::GUIComponent; + using sgg::MenuScreen; + using sgg::MiscSettingsScreen; + using sgg::Vec2; + + static constexpr std::size_t gui_component_name_offset = 0x4'88; + + // 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). + + + 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_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) + // 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) + 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) + 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) + 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) + + // 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; + + 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 + + // 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 + + 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 + + static constexpr std::uintptr_t numbox_factory_rva = 0x17'A5'30; + + static constexpr std::uintptr_t push_back_rva = 0x14'1E'D0; + + static constexpr std::uintptr_t teleport_cursor_rva = 0x14'03'A0; + + // Config/control globals move with .data/.rdata, so resolve them by name. + + // 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) + 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_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) + static constexpr std::size_t numbox_sizeof = 0x5'D0; + + // 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) + 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) + + // 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 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) + + static constexpr float button_graphic_native_width = 350.0f; + + static constexpr float button_label_capacity = 15.0f; + static constexpr float button_label_padding = 2.0f; + + // 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) + 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 + + 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) + + // GUIComponentSlider's focus look tracks mFocused directly. + static constexpr std::size_t slider_focused_offset = 0x5'48; // GUIComponentSlider::mFocused (bool) + + // 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) + + // 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) + 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) + // 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 + + 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); + 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); + 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); + + // 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); + 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); + using input_dir_pressed_fn = bool (*)(void* input_handler); + +#pragma endregion + +#pragma region Native bindings, panel model, and menu state + + struct HashGuid + { + std::uint32_t m_id; + }; + + using hash_lookup_fn = HashGuid* (*)(HashGuid * out, const char* str, std::size_t len); + + // 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; + 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; + 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; + 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; + // Native slider GetArea unions its sub-components into a screen-spanning rect. + static constexpr std::size_t slider_vtable_slot_count = 128; + 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; + + // 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; + static teleport_cursor_fn g_teleport_cursor = nullptr; + static set_mouse_over_fn g_set_mouse_over = nullptr; + static 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, validated in the PDB. + static constexpr int key_escape = 0; + static constexpr int key_kp_enter = 113; + static constexpr int key_return = 127; + + static std::uint32_t g_blank_graphic = 0; + + // 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 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) + 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 button_extra_lead = 14.0f; + static constexpr float button_extra_trail = 14.0f; + + static const std::string root_section = "config"; + + // Chalk writes this placeholder per section so empty groups persist. + static constexpr const char* section_empty_key = "..."; + + // Approximate right-column value width in glyph weights. + static constexpr float value_display_max_width = 30.0f; + + static constexpr std::uint64_t edit_cursor_blink_ms = 500; + + enum class RowKind + { + mod_entry, + group, + setting, + action, + info, + }; + + struct PanelRow + { + GUIComponent* component = nullptr; + RowKind kind = RowKind::mod_entry; + std::string stem; + std::string setting_key; + + toml_v2::config_file::config_entry_base* entry = nullptr; + + bool disabled = false; + bool is_enabled_toggle = false; + bool is_virtual_input = false; + bool is_toggle = false; + + // Fallback flip state when a virtual toggle's get() returns nil. + bool toggle_value = false; + + std::string description; + + // Mirrors component position each frame. + GUIComponent* value_component = nullptr; + + bool is_slider = false; + bool is_stepper = false; + double stepper_min = 0.0; + double stepper_max = 0.0; + double stepper_step = 1.0; + + bool show_as_percentage = false; + bool is_percentage = false; + + bool is_enum = false; + 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. + std::string config_section; + }; + + static std::vector g_rows; + + static bool g_restart_required = false; + + // Restart-causing changes keyed by setting, so re-editing overwrites its popup line. + static std::map g_restart_changes; + + static std::map g_restart_baselines; + + static GUIComponent* g_restart_confirm_button = nullptr; + + static void* g_restart_dialog = nullptr; + + static bool g_restart_prompt_shown = false; + + enum class View + { + mod_list, + mod_settings, + }; + + static View g_view = View::mod_list; + 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; + + // Stable row identity for matching after a rebuild frees components. + struct RowIdentity + { + bool valid = false; + RowKind kind; + std::string stem; + std::string section; + std::string key; + std::string config_section; + }; + + static RowIdentity g_keep_active_row; + + // 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; + + 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. + static float g_dynamic_refresh_settle = 0.0f; + + // Restore stack for backing out without losing scroll or focus. + 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; + + // 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; + 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); + +#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('-'); + const std::string name = (dash == std::string::npos) ? stem : stem.substr(dash + 1); + return key_to_display(name); + } + + 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 {}; + } + + 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); + + 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(); + } + + // 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; + out.reserve(text.size() + 8); + for (char c : text) + { + if (c == '\\' || c == '[' || c == ']') + { + out.push_back('\\'); + } + out.push_back(c); + } + return out; + } + + static int compare_display_names(const std::string& a, const std::string& b) + { + std::size_t i = 0; + std::size_t j = 0; + while (i < a.size() && j < b.size()) + { + 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')); + } + if (cb >= 'A' && cb <= 'Z') + { + cb = static_cast(cb + ('a' - 'A')); + } + if (ca != cb) + { + return ca < cb ? -1 : 1; + } + ++i; + ++j; + } + const std::size_t ra = a.size() - i; + const std::size_t rb = b.size() - j; + if (ra == rb) + { + return 0; + } + return ra < rb ? -1 : 1; + } + + static float glyph_weight(unsigned char c) + { + if (c >= 0xC0) + { + return 1.0f; + } + if (c >= 0x80) + { + return 0.0f; + } + 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; + case 'm': + case 'w': + case 'M': + case 'W': + case '@': + case '%': return 1.5f; + default: return 1.0f; + } + } + + static float measure_width(const std::string& s) + { + float w = 0.0f; + for (char c : s) + { + w += glyph_weight(static_cast(c)); + } + return w; + } + + 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; + } + + 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; + } + + 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; + } + + 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; + } + + 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; + } + + 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("..."); + std::size_t start = text.size(); + float used = 0.0f; + while (start > 0) + { + 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); + } + + 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_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; + } + + if (g_set_label) + { + g_set_label(button, "Mods"); + } + } + +#pragma endregion + +#pragma region Native row construction and styling + + 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); + } + + // 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*); + + 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; + } + + 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) + { + return nullptr; + } + + auto* row = static_cast(game_alloc(sgg::gui_component_button_size)); + if (!row) + { + return nullptr; + } + + g_button_ctor(row, screen); + *reinterpret_cast(reinterpret_cast(row) + sgg::gui_component_button_owner_offset) = screen; + return row; + } + + // 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; + auto* menu = reinterpret_cast(screen); + g_push_back(&menu->m_components, &value); + if (in_options) + { + g_push_back(&screen->m_options, &value); + } + + row->m_location_x = row_location_x; + row->m_fade_opacity = 0.0f; + + *reinterpret_cast(reinterpret_cast(row) + component_def_offset + def_fade_speed) = row_fade_speed; + } + + 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); + } + + // 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; + 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); + } + + // 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; + 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; + *reinterpret_cast(def + def_sel_text_red) = grey; + *reinterpret_cast(def + def_sel_text_green) = grey; + *reinterpret_cast(def + def_sel_text_blue) = grey; + } + + 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; + + // 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; + *reinterpret_cast(b + base + 0x4) = disabled_text_grey; + *reinterpret_cast(b + base + 0x8) = disabled_text_grey; + } + } + + // mColorTarget must be written too or the per-frame lerp undoes the grey. + 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; + } + + 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; + } + + 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; + *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; + } + } + + 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) + { + 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; + *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) = 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; + + if (disabled) + { + set_def_text_grey(row); + } + else + { + set_def_text_normal(row, no_hover_highlight); + } + + if (g_setup_component) + { + g_setup_component(row, row_bytes + component_data_offset); + } + + // SetupComponent does not clear textures a prior template already set. + 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 && block_input && g_disable) + { + g_disable(row); + } + + finalize_row(screen, row); + return row; + } + + 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) + { + 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; + + 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) + { + grey_toggle_graphic(reinterpret_cast(row)); + + if (block_input && g_disable) + { + g_disable(row); + } + } + + finalize_row(screen, row); + return row; + } + + 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) + { + 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); + + 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; + *reinterpret_cast(def + def_scale) = button_scale; + + // 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; + + *reinterpret_cast(def + def_deselect_on_mouse_off) = true; + + if (disabled) + { + set_def_text_grey(row); + } + + if (g_setup_component) + { + g_setup_component(row, row_bytes + component_data_offset); + } + + // 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; + } + + if (g_set_label) + { + g_set_label(row, label); + } + + if (box_scale_x > 1.0f) + { + *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; + *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); + } + + // CategoryOptionsButton leaves mFreeFormSelectable unset. + if (!disabled) + { + *reinterpret_cast(row_bytes + component_free_form_selectable_offset) = true; + install_wide_button_nav_rect(row); + } + + finalize_row(screen, row); + + row->m_location_x = button_center_x; + 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); + 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; + *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; + *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_normal(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); + } + disable_text_markup(row); + if (g_set_label) + { + g_set_label(row, text); + } + + row->m_can_be_focused = false; + + finalize_row(screen, row, false); + return row; + } + + static bool is_whole(double v) + { + return std::isfinite(v) && v == std::floor(v); + } + + 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, escape_markup(text).c_str()); + } + } + + 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) + { + 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); + + 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"); + } + + // 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 OptionNumBox's own row grid, so override it. + { + char* def = nb_bytes + component_def_offset; + *reinterpret_cast(def + def_y) = row_base_y; + *reinterpret_cast(def + def_spacing) = row_pitch; + } + + if (void* label_tb = *reinterpret_cast(nb_bytes + numbox_label_text_offset)) + { + g_show_text(label_tb, label); + } + + // 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()) + { + 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; + grey_text_box(*reinterpret_cast(nb_bytes + numbox_label_text_offset)); + grey_text_box(*reinterpret_cast(nb_bytes + numbox_value_text_offset)); + if (block_input) + { + 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; + } + } + } + + finalize_row(screen, nb); + 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. + 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; + + 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); + if (out.find('.') != std::string::npos) + { + const std::size_t last = out.find_last_not_of('0'); + out.erase((out[last] == '.') ? last : last + 1); + } + if (show_as_pct || is_pct) + { + out += "%"; + } + return out; + } + + // 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) + { + return; + } + if (void* value_tb = *reinterpret_cast(reinterpret_cast(slider) + slider_value_text_offset)) + { + g_show_text(value_tb, text); + } + } + + static void* row_bounded_area(GUIComponent* self, std::int32_t* out) + { + 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[3] = 44; + return out; + } + + 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); + } + + static void install_wide_button_nav_rect(GUIComponent* row) + { + 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); + } + *reinterpret_cast(row) = g_button_vtable_patched; + } + + // 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) + { + return nullptr; + } + + char* s = static_cast(game_alloc(slider_sizeof)); + if (!s) + { + return nullptr; + } + std::memset(s, 0, slider_sizeof); + + // 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. + 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; + + // 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)); + char* val = static_cast(game_alloc(textbox_sizeof)); + if (!backing || !fill || !lbl || !val) + { + game_free(backing); + game_free(fill); + game_free(lbl); + game_free(val); + game_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; + + // SetParent only writes mParentContainer. + *reinterpret_cast(s + slider_parent_offset) = reinterpret_cast(screen) + menu_screen_container_offset; + + set_sso_string(s + gui_component_name_offset, "OptionSlider"); + set_sso_string(val + gui_component_name_offset, "OptionSliderValueText"); + + g_apply_data(reinterpret_cast(screen), reinterpret_cast(s)); + + { + 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); + } + + // 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); + set_slider_value_text(reinterpret_cast(s), + format_setting_display(initial, show_as_pct, is_pct, step_v).c_str()); + + if (disabled) + { + // Native drag ignores mIsUseable, so HandleInput blocks it separately. + 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)); + grey_image(*reinterpret_cast(s + slider_fill_offset)); + } + + finalize_row(screen, reinterpret_cast(s)); + reinterpret_cast(s)->m_location_x = slider_location_x; + return reinterpret_cast(s); + } + +#pragma endregion + +#pragma region Row teardown and mod list + + 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; + } + } + } + + // Our rows are not registered in the reflection helper. + static void destroy_rows(MiscSettingsScreen* screen) + { + auto* menu = reinterpret_cast(screen); + + auto unlink_and_free = [&](GUIComponent* comp, bool in_options, bool owns_subcomponents) + { + if (!comp) + { + return; + } + 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; + } + if (g_edit_component == comp) + { + g_edit_component = nullptr; + } + + vector_erase(menu->m_components, comp); + if (in_options) + { + vector_erase(screen->m_options, comp); + } + + if (owns_subcomponents) + { + // 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); + } + else if (g_button_dtor) + { + g_button_dtor(comp); + } + game_free(comp); + }; + + for (const auto& row : g_rows) + { + unlink_and_free(row.component, true, row.is_stepper || row.is_enum || row.is_slider); + unlink_and_free(row.value_component, false, false); + } + + g_rows.clear(); + } + + static std::string no_settings_note() + { + 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); + 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; + for (const auto* cfg : toml_v2::config_file::g_config_files) + { + if (!cfg || cfg->m_config_file_stem_as_str.empty()) + { + continue; + } + + 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); + } + } + + std::vector> mods; + 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 compare_display_names(a.first, b.first) < 0; + }); + + for (const auto& [display, stem] : mods) + { + // 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 = 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)); + } + } + } + +#pragma endregion + +#pragma region Value formatting, freetext editing, and commit + + static std::string key_to_display(const std::string& key) + { + 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); + } + + for (char& c : out) + { + if (c != ' ') + { + if (is_lower(c)) + { + c = static_cast(c - ('a' - 'A')); + } + break; + } + } + return out; + } + + // 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 ? "|" : " "; + const std::size_t len = buf.size(); + if (cursor > len) + { + cursor = len; + } + + const float caret_w = 0.6f; + const float ellipsis_w = measure_width("..."); + + if (measure_width(buf) + caret_w <= value_display_max_width) + { + return escape_markup(buf.substr(0, cursor)) + caret + escape_markup(buf.substr(cursor)); + } + + 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; + } + + static bool numeric_char_ok(const std::string& buffer, std::size_t cursor, char c) + { + if (c >= '0' && c <= '9') + { + return true; + } + if (c == '-' || c == '+') + { + return cursor == 0 && (buffer.empty() || (buffer.front() != '-' && buffer.front() != '+')); + } + if (c == '.') + { + 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 submits the edit. + static void on_wndproc(HWND, UINT msg, WPARAM wparam, LPARAM) + { + if (!g_editing) + { + return; + } + + if (msg == WM_LBUTTONDOWN || msg == WM_RBUTTONDOWN) + { + g_edit_confirm = true; + return; + } + + if (g_edit_cursor > g_edit_buffer.size()) + { + g_edit_cursor = g_edit_buffer.size(); + } + + if (msg == WM_KEYDOWN) + { + const bool ctrl = (GetKeyState(VK_CONTROL) & 0x80'00) != 0; + switch (wparam) + { + 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; + } + + if (msg == WM_CHAR) + { + const unsigned c = static_cast(wparam); + if (c < 32 || c >= 127) + { + return; + } + const char ch = static_cast(c); + if (g_edit_numeric && !numeric_char_ok(g_edit_buffer, g_edit_cursor, ch)) + { + return; + } + g_edit_buffer.insert(g_edit_cursor, 1, ch); + ++g_edit_cursor; + } + } + + static void ensure_wndproc_registered() + { + static bool registered = false; + if (registered || !g_renderer || !g_feature_enabled) + { + 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* value_component, toml_v2::config_file::config_entry_base* entry) + { + ensure_wndproc_registered(); + g_editing = true; + 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(); + 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() + { + g_editing = false; + 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_had_mouse = false; + } + + 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 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) + { + 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()); + } + + static std::string current_language_code() + { + return g_config_language ? std::string(g_config_language) : std::string(); + } + + static std::string resolve_localized(const localized_text& t) + { + if (t.empty()) + { + return {}; + } + if (t.size() == 1) + { + return t.begin()->second; + } + 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; + } + + static bool g_view_has_dynamic = false; + + // 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) + { + 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)) + { + return dynamic; + } + } + return meta; + } + + 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); + if (meta) + { + if (std::string name = resolve_localized(meta->name); !name.empty()) + { + return 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 + // 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) + { + 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) + { + g_restart_changes.erase(key); + } + else + { + 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(); + } + + // 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; + } + + 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; + 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, 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, row_io_section(row), row->setting_key, nv); + changed = true; + } + } + if (changed && commit_may_change_other_rows(row)) + { + 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, 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, row_io_section(row), row->setting_key, nv); + changed = true; + } + } + if (changed && commit_may_change_other_rows(row)) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } + return changed; + } + + // 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; + 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, 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, row_io_section(row), row->setting_key, nv); + changed = true; + } + } + if (changed && commit_may_change_other_rows(row)) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } + return changed; + } + + 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()); + } + } + + // Runs on the same frame HandleInput swallows the triggering key or click. + static bool commit_or_cancel_edit() + { + if (g_edit_confirm) + { + if (g_edit_entry) + { + capture_restart_baseline(g_edit_entry); + + // Bad numeric input keeps the previous value because set_serialized_value validates before saving. + g_edit_entry->set_serialized_value(g_edit_buffer); + + // 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, + 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); + } + g_edit_entry->set_value_base(v); + } + } + + 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. + refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); + + if (g_view_has_dynamic || static_cast(g_edit_entry->m_setting_changed)) + { + g_dynamic_refresh_settle = dynamic_refresh_settle_seconds; + } + } + exit_edit_mode(); + return true; + } + if (g_edit_cancel) + { + if (g_edit_entry) + { + refresh_value_display(g_edit_component, g_edit_entry->get_serialized_value()); + } + exit_edit_mode(); + return true; + } + return false; + } + + static void update_edit_label() + { + if (g_edit_component && g_set_label) + { + 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()); + } + } + +#pragma endregion + +#pragma region Editability context and menu-path helpers + + static bool is_enabled_key(const std::string& key) + { + return big::string::to_lower(key) == "enabled"; + } + + static bool entry_has_description(const toml_v2::config_file::config_entry_base* entry) + { + return entry && !entry->m_description.m_description.empty(); + } + + static bool g_opened_in_game = false; + + // CurrentHubRoom is non-nil in the Crossroads, nil in a run. + static bool g_in_hub = false; + + static bool g_options_screen_open = false; + + bool on_change_callbacks_enabled() + { + return g_options_screen_open; + } + + static constexpr std::size_t game_screen_get_type_vtable_slot = 10; + static constexpr int screen_type_pause = 0x10'00'03; + + 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; + } + + 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; + } + + static bool is_context_restricted(editable_context ctx) + { + switch (ctx) + { + 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; + } + } + + 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."; + case editable_context::in_hub: return "This setting can only be changed while in the Crossroads."; + default: return {}; + } + } + + // 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()) + { + return description; + } + if (description.empty()) + { + return note; + } + return note + "\n" + description; + } + + // 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) + { + return false; + } + toml_v2::config_definition def(section, key); + return cfg->try_get_entry(def) != nullptr; + } + + static std::set g_warned_group_overrides; + + 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; + } + + 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; + } + + 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; + } + + // 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()) + { + return csection; + } + const std::string m = menu_path_of(csection, group); + if (find_author_group(author_groups, m)) + { + return m; + } + if (view_cfg) + { + 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; + } + + static bool menu_path_in_scope(const std::string& p, const std::string& scope) + { + return p == scope || p.rfind(scope + ".", 0) == 0; + } + +#pragma endregion + +#pragma region Panel builder + + struct panel_item + { + bool is_group = false; + 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(). + }; + + struct panel_contents + { + std::vector items; + toml_v2::config_file::config_entry_base* enabled_entry = nullptr; + toml_v2::config_file* view_cfg = nullptr; + bool mod_enabled = true; + }; + + 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; + toml_v2::config_file*& view_cfg = out.view_cfg; + const std::string section_prefix = section + "."; + + const std::vector author_groups = mod_menu_groups(stem); + + 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); + }; + + 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; + }; + + auto ensure_group = [&](const std::string& child_path) + { + if (groups.contains(child_path)) + { + return; + } + panel_item g; + g.is_group = true; + g.child_section = child_path; + 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.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; + } + + // Dynamic fields are skipped at load, so re-evaluate the declaration now. + 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); + + // 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; + g.order = meta->order; + } + if (!config_child_exists(view_cfg, child_path, "editableContext")) + { + g.group_context = meta->context; + } + } + if (g.sort_name.empty()) + { + g.sort_name = key_to_display(g.key); + } + 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 || cfg != live_config_file(stem)) + { + continue; + } + view_cfg = cfg; + for (auto& [key, entry] : cfg->m_entries) + { + if (!entry || key.m_key == section_empty_key) + { + continue; + } + + // 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)) + { + continue; + } + + // 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); + 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; + 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.config_section = key.m_section; + 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; + it.order = meta->order; + } + items.push_back(std::move(it)); + } + else if (place == 2) + { + ensure_group(child_path); + } + } + } + + for (auto& kv : groups) + { + items.push_back(std::move(kv.second)); + } + + // Actions are bucketed by menu path like settings. + 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); + continue; + } + if (place != 1) + { + continue; + } + panel_item it; + it.is_action = true; + it.key = a.key; + it.config_section = a.section; + it.has_order = a.has_order; + it.order = a.order; + 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)); + } + + 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); + continue; + } + if (place != 1) + { + continue; + } + 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.config_section = vr.section; + it.has_order = vr.has_order; + it.order = vr.order; + it.sort_name = setting_display_name(stem, vr.section, vr.key); + items.push_back(std::move(it)); + } + + 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 == out.enabled_entry) + { + it.is_enabled = true; + } + } + } + + // 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; + } + if (a.is_enabled) + { + return false; + } + if (a.has_order != b.has_order) + { + return a.has_order; + } + if (a.has_order && a.order != b.order) + { + return a.order < b.order; + } + return compare_display_names(a.sort_name, b.sort_name) < 0; + }); + + return out; + } + + 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; + + // 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; + const bool disabled = !is_enabled_row && !mod_enabled; + + 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 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); + + if (auto* row = make_button_row(screen, label.c_str(), act_disabled, /*block_input*/ mod_off)) + { + if (mod_off) + { + row->m_can_be_focused = false; + *reinterpret_cast(reinterpret_cast(row) + sgg::gui_component_button_selectable_offset) = false; + } + else + { + // 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) + { + g_set_selected_texture(row, 0); + } + } + PanelRow pr{row, RowKind::action, stem, it.key}; + pr.disabled = act_disabled; + pr.target_section = it.action.section; + + 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; + } + + if (it.is_virtual) + { + // `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{}; + + 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; // 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)); + } + }; + + if (!it.virtual_interactive) + { + build_readonly(get_virtual_display(stem, vsection, it.key)); + continue; + } + + // 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; + 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; + + 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; + } + + 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; + } + } + } + + 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.config_section = vsection; // real config section. + 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 + { + // Interactive free-text virtual rows are not supported yet. + 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.config_section = vsection; // real config section. + 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); + pr.enum_index = enum_index; + } + 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; + pr.toggle_value = vv.as_bool; // fallback when get() is nil. + } + g_rows.push_back(pr); + } + continue; + } + + if (it.is_group) + { + 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 can also describe config children of the same name. + 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 (config_child_exists(view_cfg, it.child_section, "disabled")) + { + gmeta->disabled = false; + } + } + + if (gmeta && gmeta->hidden) + { + 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{}; + gdisabled_description = gmeta ? resolve_localized(gmeta->disabled_description) : std::string{}; + group_disabled = gmeta && gmeta->disabled; + } + + // Context-blocked categories are greyed and cannot be entered. + const bool ctx_blocked = is_context_restricted(it.group_context); + + // 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; + pr.target_section = it.child_section; + + 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; + } + + const std::string& key = it.key; + auto* entry = it.entry; + + const auto meta = resolved_metadata(stem, entry->m_definition.m_section, entry->m_definition.m_key); + if (meta && meta->hidden) + { + continue; + } + + // 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)); + + 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; + + std::vector enum_values; + std::vector enum_labels; + int enum_index = 0; + if (is_enum) + { + enum_values = meta->values; + + 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) + { + if (enum_values[i] == cur) + { + enum_index = i; + break; + } + } + } + + GUIComponent* row = nullptr; + GUIComponent* value = nullptr; + bool built_slider = false; + bool built_stepper = false; + bool built_enum = false; + bool built_toggle = false; + + 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. + GUIComponent* ro_row = nullptr; + GUIComponent* ro_value = nullptr; + bool ro_is_toggle = false; + bool ro_is_enum = false; + bool ro_is_numbox = false; + 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_is_toggle = ro_row != nullptr; + } + else if (is_enum) + { + 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) + { + 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; + } + } + if (!ro_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 (ro_row) + { + PanelRow pr{ro_row, RowKind::setting, stem, key, entry}; + 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; + pr.is_stepper = ro_is_numbox; + 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; + pr.enum_index = enum_index; + } + else if (ro_is_toggle) + { + pr.is_toggle = true; + } + else + { + pr.value_component = ro_value; + } + + 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; + } + + if (entry->type() == typeid(bool)) + { + 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) + { + 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); + built_stepper = row != nullptr; + } + } + else + { + row = make_text_row(screen, label.c_str(), disabled); + if (row) + { + const std::string v = entry ? entry->get_serialized_value() : std::string{}; + value = make_value_display(screen, escape_markup(truncate_value(v)).c_str(), disabled); + } + } + + if (row) + { + PanelRow pr{row, RowKind::setting, stem, key, entry}; + pr.disabled = disabled; + pr.is_enabled_toggle = is_enabled_row; + pr.value_component = value; + + const std::string mdesc = meta ? resolve_localized(meta->description) : std::string{}; + pr.description = !mdesc.empty() ? mdesc : entry->m_description.m_description; + + if (built_enum) + { + 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) + { + pr.is_slider = 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 (built_toggle) + { + pr.is_toggle = true; + } + + g_rows.push_back(pr); + } + } + } + + 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 + + // 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; + 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 outside mOptions, so mirror the key row's position and fade. + 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 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; + } + + static PanelRow* find_row(GUIComponent* comp) + { + if (!comp) + { + return nullptr; + } + for (auto& row : g_rows) + { + if (row.component == comp) + { + return &row; + } + } + return nullptr; + } + + 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, row_config_section_of(r)}; + } + + // Re-finds a selectable 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_config_section_of(row) == id.config_section && !row.disabled && c->m_is_useable && !c->m_hidden) + { + return c; + } + } + return nullptr; + } + + // 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)) + { + return true; + } + auto* menu = reinterpret_cast(screen); + 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; + } + + static GUIComponent* g_last_description_component = nullptr; + + static void sync_description_box(MiscSettingsScreen* screen) + { + if (!g_show_text || !screen->m_description_box) + { + return; + } + auto* box = screen->m_description_box; + + GUIComponent* active = active_row_component(screen); + + const std::string* description = nullptr; + if (PanelRow* row = find_row(active)) + { + description = &row->description; + } + const bool show = description && !description->empty(); + + if (active != g_last_description_component) + { + g_last_description_component = active; + + 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 lines dirty, so force layout now to avoid a first-frame jump. + if (show && g_get_lines) + { + g_get_lines(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; + } + + static std::string g_prompt_confirm_label; + static std::string g_prompt_cancel_label; + + // 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) + { + return; + } + cache.assign(text); + g_set_label(button, text); + } + + // 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) + { + g_prompt_confirm_label.clear(); + g_prompt_cancel_label.clear(); + return; + } + + auto* menu = reinterpret_cast(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); + + std::string confirm; + if (g_editing) + { + confirm = "{CF} SUBMIT"; + } + else if (PanelRow* row = find_row(active_row_component(screen))) + { + if (row->disabled) + { + confirm.clear(); + } + else + { + switch (row->kind) + { + case RowKind::mod_entry: confirm = "{SL} SELECT"; break; + case RowKind::group: confirm = "{SL} SELECT"; break; + case RowKind::setting: + if (row->is_toggle) + { + confirm = "{SL} TOGGLE"; + } + else if (row->is_enum) + { + confirm = "{SL} SET"; + } + else if (row->is_slider) + { + confirm = "{SL} SET"; + } + else if (row->is_stepper) + { + confirm = "{SL} SELECT"; + } + else + { + confirm = "{SL} EDIT"; + } + break; + case RowKind::action: confirm = "{SL} SELECT"; break; + } + } + } + + // Native OnOptionMouseOver never fires for our custom rows. + 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; + } + } + + // 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; + screen->m_defaults_button->m_hidden = !show_reset; + } + } + + // 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); // next Update focuses it. + screen->m_category_focused = false; // hand nav from tabs to rows. + } + + 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) + { + focus_row(screen, c); + return; + } + } + } + + // 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()) + { + 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()); + + 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; + } + } + } + + auto* menu = reinterpret_cast(screen); + if (!target || menu->m_mouse_over_component == target) + { + return; + } + + g_set_mouse_over(screen, target); + g_teleport_cursor(screen, target); + screen->m_category_focused = false; + } + + static GUIComponent* restore_target_row(const NavRestore& r) + { + for (const auto& row : g_rows) + { + 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; + } + } + return nullptr; + } + + 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; + } + + // 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) + { + return false; + } + return (g_input_get_state(input, control) & 0x4u) != 0; + } + + 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; + + g_prompt_confirm_label.clear(); + g_prompt_cancel_label.clear(); + g_last_description_component = nullptr; + } + + // 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); + void* fn = *reinterpret_cast(vtable + vtable_byte_offset); + reinterpret_cast(fn)(comp); + } + + 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); + } + + // Sliders revert through OnMouseOff, num-boxes through OnUnselected. + static void clear_stale_widget_highlight(MiscSettingsScreen* screen) + { + auto* menu = reinterpret_cast(screen); + const bool mouse_mode = g_use_mouse && *g_use_mouse; + for (const auto& row : g_rows) + { + if (!row.component) + { + continue; + } + char* s = reinterpret_cast(row.component); + + if (row.is_slider) + { + 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)) + { + call_component_vfn(row.component, vtable_on_mouse_off_offset); + } + } + + 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 || row.disabled)) + { + 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)) + { + call_component_vfn(row.component, vtable_on_unselected_offset); + } + } + } + } + } + + 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); + } + } + } + + // mFreeFormSelectable makes spatial nav skip disabled rows without blocking mouse hover. + 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) + { + g_last_description_component = nullptr; + + // In-place refreshes preserve the current scroll offset. + const std::uint32_t prev_start = screen->m_page_start_index; + + // 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; + 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; + } + } + + destroy_rows(screen); + + // "Blank" only hashes correctly once the string-intern table is ready. + if (!g_blank_graphic && g_hash_lookup) + { + HashGuid res{}; + g_hash_lookup(&res, "Blank", 5); + g_blank_graphic = res.m_id; + } + + g_view_has_dynamic = false; + + 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); + } + else + { + build_mod_list(screen); + } + + // 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) + { + // 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; + start = desired > last_page_start ? last_page_start : desired; + } + screen->m_page_start_index = start; + screen->m_options_per_page = rows_per_page; + if (g_update_scroll) + { + g_update_scroll(screen); // sets each row's mFadeTarget. + } + + if (instant) + { + // Snap in-place refreshes to their final visibility so the panel does not flash. + for (const auto& row : g_rows) + { + if (row.component) + { + row.component->m_fade_opacity = row.component->m_fade_target; + } + } + } + + sync_value_columns(); + + apply_row_freeform_selectability(); + + // 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; + if (restore_focus) + { + focus_row(screen, restore_focus); + } + else + { + focus_first_row(screen); + } + } + else if (had_cursor) + { + 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 (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; + } + + g_commit_guard_frames = commit_guard_frame_count; + } + + static void apply_nav(MiscSettingsScreen* screen) + { + const bool instant = (g_pending_view == g_view) && (g_pending_stem == g_view_stem) && (g_pending_section == g_view_section); + + 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) + { + NavRestore r; + r.scroll_index = screen->m_page_start_index; + if (g_view == View::mod_list) + { + r.focus_stem = g_pending_stem; + } + else + { + r.focus_section = g_pending_section; + } + g_nav_stack.push_back(std::move(r)); + } + else if (backing_out && !g_nav_stack.empty()) + { + g_pending_restore = g_nav_stack.back(); + g_nav_stack.pop_back(); + g_has_pending_restore = true; + } + + g_view = g_pending_view; + g_view_stem = g_pending_stem; + g_view_section = g_pending_section; + build_panel(screen, instant); + } + +#pragma endregion + +#pragma region Reset to defaults + + // 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) + { + 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()); + } + + // 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; // 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 + || cfg != live_config_file(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) + { + if (!entry) + { + continue; + } + 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); + 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; + } + + 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) + { + // Chalk mods recover defaults from the config entry itself. + def_val = entry_default_serialized(e); + } + 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; + } + } + + // Interactive virtual rows with a `default` are reset through set(). + for (const auto& vr : get_virtual_rows(g_view_stem, "")) + { + 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; + } + + 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; + } + } + +#pragma endregion + +#pragma region Native dialogs and dependency checks + + // 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; + } + + // 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 or U+00A0. + + const auto spaced = [cjk](const std::string& s) -> std::string + { + if (cjk) + { + return s; + } + 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(intro); + msg += "\n" + blank + "\n"; + for (const auto& line : lines) + { + msg += spaced(line); + msg += "\n"; + } + msg += blank + "\n"; + msg += spaced(outro); + msg += "\n" + blank; + return msg; + } + + 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 EASTL SSO string. + 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); + } + + // Forced restart skips OnExit, so SaveProfile must flush native Options settings first. + static void flush_native_settings() + { + if (g_save_profile && g_active_profile) + { + g_save_profile(g_active_profile, false, false); + } + } + + // 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) + { + // 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); + + char empty_message[24]; + make_eastl_sso(empty_message, ""); + g_message_dialog_ctor(dialog, screen_manager, empty_message); + + auto* bytes = reinterpret_cast(dialog); + + bytes[screen_removed_offset] = 0; + bytes[screen_visible_offset] = 1; + bytes[screen_block_input_offset] = 1; + + if (g_show_text) + { + if (auto* title_box = *reinterpret_cast(bytes + dialog_title_offset)) + { + g_show_text(title_box, title); + } + if (auto* message_box = *reinterpret_cast(bytes + dialog_message_offset)) + { + 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; + + const std::string shown = escape_markup(message); + g_show_text(message_box, shown.c_str()); + } + } + + // 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; + } + + char empty_name[24]; + make_eastl_sso(empty_name, ""); + g_add_screen(screen_manager, dialog, true, empty_name); + return true; + } + } + + return false; + } + + 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); + } + + 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); + } + + static bool mod_is_enabled(const std::string& guid) + { + if (auto* cfg = live_config_file(guid)) + { + for (auto& [key, entry] : cfg->m_entries) + { + if (!entry || key.m_section != root_section || entry->type() != typeid(bool) || !is_enabled_key(key.m_key)) + { + 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; + } + + 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; + } + + 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."); + } + +#pragma endregion + +#pragma region Engine hooks + + static void* hook_MiscSettingsScreen_ctor(void* self, void* screen_manager, void* opened_from, void* profile_name) + { + // The original ctor may build our panel via DoShowCategory. + 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_stack.clear(); + g_has_pending_restore = false; + 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; + g_prompt_confirm_label.clear(); + g_prompt_cancel_label.clear(); + exit_edit_mode(); + + // 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; + + auto* screen = static_cast(big::g_hooking->get_original()(self, screen_manager, opened_from, profile_name)); + + 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); + + // Tear down rows before the native category switch so mComponents stays clean. + 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); + + if (is_mods_tab) + { + g_view = View::mod_list; + g_view_stem.clear(); + g_view_section.clear(); + g_nav_pending = false; + g_nav_stack.clear(); + g_has_pending_restore = false; + exit_edit_mode(); + build_panel(screen); + } + + return result; + } + + 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->entry && !row->is_virtual_input)) + { + return; + } + + if (row->is_enum) + { + 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; + } + + if (!row->is_stepper) + { + return; + } + + const double new_value = static_cast(*reinterpret_cast(reinterpret_cast(self) + numbox_value_offset)); + commit_row_number(row, new_value); + } + + // 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); + + if (!notify || !self) + { + return; + } + + PanelRow* row = find_row(reinterpret_cast(self)); + if (!row || !row->is_slider || (!row->entry && !row->is_virtual_input) || 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; + + 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; + } + + commit_row_number(row, v); + + set_slider_value_text(reinterpret_cast(self), + format_setting_display(v, row->show_as_percentage, row->is_percentage, step_v).c_str()); + } + + // 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)) + { + 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 cur = row->entry ? row->entry->get_value_base() : + 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) + { + v = min_v; + } + else if (v > max_v) + { + v = max_v; + } + g_slider_set_fraction(slider, static_cast((v - min_v) / range), true); + } + + // 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; + + // 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; + if (row && row->is_slider) + { + if (row->disabled) + { + 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)) + { + // 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); + + if (self != g_slider_repeat_component) + { + g_slider_repeat_component = self; // focus moved, restart repeat. + g_slider_repeat_dir = 0; + g_slider_repeat_timer = 0.0f; + } + + bool step_now = false; + if (dir == 0) + { + g_slider_repeat_timer = 0.0f; + } + else if (dir != g_slider_repeat_dir) + { + step_now = true; // fresh press steps immediately. + g_slider_repeat_timer = slider_repeat_delay; + } + 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 frames that moved the value. + } + return false; // still block native continuous slide. + } + } + return big::g_hooking->get_original()(self, input, dt); + } + + static bool hook_GUIComponentButton_OnClicked(GUIComponent* self, std::uint64_t location) + { + // 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); + flush_native_settings(); + TerminateProcess(GetCurrentProcess(), 0); + } + + PanelRow matched_row; + bool matched = false; + + if (self) + { + for (const auto& row : g_rows) + { + if (row.component == self) + { + matched_row = row; + matched = true; + break; + } + } + } + + // 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)) + { + 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 + && matched_row.is_toggle) + { + // 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); + } + + // Disabled rows stay hoverable for notes but must not play press feedback. + if (matched && matched_row.disabled) + { + return false; + } + + const bool result = big::g_hooking->get_original()(self, location); + + if (matched && !matched_row.disabled) + { + switch (matched_row.kind) + { + case RowKind::mod_entry: + 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: + { + auto* entry = matched_row.entry; + + if (entry && entry->type() == typeid(bool)) + { + const bool new_value = !entry->get_value_base(); + + // 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); + 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; // leave the toggle on. + } + } + + // 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); + + 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 || commit_may_change_other_rows(&matched_row)) + { + g_pending_view = View::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) + { + enter_edit_mode(matched_row.value_component, entry); + } + else if (matched_row.is_virtual_input && matched_row.is_toggle) + { + // 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; + commit_row_bool(&matched_row, new_value); + set_toggle_graphic(self, new_value); + } + break; + } + case RowKind::action: + + // 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; + g_pending_section = g_view_section; + g_nav_pending = true; + g_keep_active_row = row_identity_of(matched_row); + break; + } + } + + return result; + } + + 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); + } + } + } + + // 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()) + { + 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; + }; + + aim(screen->m_down_arrow, g_rows[last].component, row_pitch); + aim(screen->m_up_arrow, g_rows[first].component, -row_pitch); + } + + // Runs inside MiscSettingsScreen::Update before row hit-tests. + 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) + { + apply_button_spacing(screen); + enable_arrow_keyboard_paging(screen); + } + } + + // 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); + + if (g_editing) + { + if (on_mods_tab) + { + update_edit_label(); + } + else + { + exit_edit_mode(); // never stay in edit mode off the Mods tab. + } + } + + // 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) + { + g_dynamic_refresh_settle = 0.0f; + } + else if (interacting_with_row(screen, input)) + { + 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 (GUIComponent* active = active_row_component(screen)) + { + if (const PanelRow* fr = find_row(active)) + { + g_keep_active_row = row_identity_of(*fr); + } + } + } + } + } + } + + if (g_nav_pending) + { + if (on_mods_tab) + { + // 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) + { + 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; + } + + // 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); + if (--g_keep_active_frames == 0) + { + g_keep_active_row.valid = false; + } + } + + if (g_commit_guard_frames > 0) + { + --g_commit_guard_frames; + } + + void* result = big::g_hooking->get_original()(self, dt, input); + + if (on_mods_tab) + { + sync_scroll_fade(screen); + sync_value_columns(); + sync_description_box(screen); + } + + sync_prompts(screen, on_mods_tab); + + // Widgets clear greyed-label colour from mIsUseable each frame. + if (on_mods_tab) + { + clear_stale_widget_highlight(screen); + keep_disabled_labels_grey(); + } + + return result; + } + + // Committing here also swallows a submitting mouse click. + static bool hook_MiscSettingsScreen_HandleInput(void* self, void* input, float x) + { + 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)) + { + g_edit_confirm = true; + } + if (g_was_key_pressed(input, key_escape)) + { + g_edit_cancel = true; + } + } + commit_or_cancel_edit(); + 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); + + 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. + } + } + + if (g_view == View::mod_settings && !g_nav_pending && control_pressed(input, g_controls_cancel)) + { + request_back_nav(); + return true; + } + } + + // 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; + + 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 while mScreenManager is valid. + static void hook_MiscSettingsScreen_ExitScreen(void* 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 && g_view == View::mod_settings) + { + request_back_nav(); + return; // veto close, apply_nav runs next Update. + } + + 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; + } + } + + // 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); + } + + // 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); + if (screen->m_current_category_button == reinterpret_cast(screen->m_editor_options_button)) + { + if (g_view != View::mod_settings) + { + return; // do not play native reset in the mod list. + } + perform_reset(); + } + + big::g_hooking->get_original()(self); + } + +#pragma endregion + +#pragma region Hook registration + + void register_hooks() + { + // Resolve symbols, RVAs and offsets up front against the validated Ship build. + 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; + }; + + 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 update_scroll = require("sgg::MiscSettingsScreen::UpdateScrollState"); + const auto handle_input = require("sgg::MiscSettingsScreen::HandleInput"); + const auto set_number_value = require("sgg::GUIComponentNumBox::SetNumberValue"); + + // Required helpers. The button ctor anchors later RVA fallbacks. + const auto anchor = require("sgg::GUIComponentButton::GUIComponentButton"); + g_button_ctor = anchor.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(); + 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_push_back = + big::hades2_symbol_to_address["eastl::vector::push_back"].as_func(); + + // 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 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(); + 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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")); + 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)"); + } + + // 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. + }; + 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); + + // 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 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); + } + if (!g_push_back) + { + missing.push_back("eastl::vector::push_back"); + } + + if (!missing.empty() || !build_matches || !build_validated) + { + std::string detail; + for (const auto* name : missing) + { + 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; add this GUID to validated_pdb_guids 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; 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." + << detail; + return; + } + + // 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); + + // 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(); + } + else + { + g_slider_vtable = anchor_base + slider_vtable_rva; + } + + // 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); + } + + g_feature_enabled = true; + + 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); + + // 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); + static auto snv_hook = hooking::detour_hook_helper::add_queue( + "sgg::GUIComponentNumBox::SetNumberValue", + set_number_value); + + // 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); + + // 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) + { + 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); + 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); + + // ExitScreen is the restart-required prompt funnel. + 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"; + } + + // Optional RestoreDefaults hook for the Reset button. + 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"; + } + } + +#pragma endregion + +} // 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..eb570ad --- /dev/null +++ b/src/hades2/mod_settings/mod_settings.hpp @@ -0,0 +1,194 @@ +#pragma once + +#include +#include +#include +#include + +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; + + // Off-context rows are greyed. main_menu is forced for the master toggle and restartRequired settings. + enum class editable_context + { + any, + main_menu, + in_save, + in_hub, + }; + + // Pins a virtual row's widget kind when get() cannot provide one. + enum class widget_type + { + inferred, + boolean, + number, + string, + enumeration, + }; + + // 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; + editable_context context = editable_context::any; + bool has_dynamic = false; + std::vector children; + }; + + std::vector mod_menu_groups(const std::string& guid); + + // 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); + + // Metadata from a rich config.lua description table. All fields are optional. + struct setting_metadata + { + localized_text name; // display-name override. + localized_text description; // .cfg comment text. + + // Author-disabled rows may override `description`. + localized_text disabled_description; + + 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 serialize like config values. Labels default to values. + std::vector values; + std::vector labels; + + bool has_order = false; + double order = 0.0; // lowest first, unset means alphabetical by display name. + + bool hidden = false; + bool disabled = false; // greyed and non-interactive, may be dynamic. + bool restart_required = false; + + editable_context context = editable_context::any; + + // Lua-function fields are re-evaluated at render. + bool has_dynamic = false; + + // is_percentage scales display by 100. show_as_percentage only appends "%". + bool show_as_percentage = false; + bool is_percentage = false; + + // Virtual-row only. Reset restores `default` through set(). + widget_type type = widget_type::inferred; + bool has_default = false; + std::string default_value; + + // Empty means config-section placement. Segments are config child sections or declared groups. + std::vector group; + }; + + bool setting_requires_restart(const std::string& guid, const std::string& section, const std::string& key); + + std::optional get_setting_metadata(const std::string& guid, const std::string& section, const std::string& key); + + bool setting_is_described(const std::string& guid, const std::string& section, const std::string& key); + + // Gates `editableContext = "inHub"` rows via the game's `CurrentHubRoom` global. + bool game_is_in_hub(); + + // 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); + + // Button backed by a configDesc `action` function with no config value. + struct action_info + { + 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; + 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. + }; + + // Returned action fields are resolved against the current game state. + std::vector get_actions(const std::string& guid, const std::string& section); + + void invoke_action(const std::string& guid, const std::string& section, const std::string& key); + + // 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; // 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. + }; + + std::vector get_virtual_rows(const std::string& guid, const std::string& section); + + std::string get_virtual_display(const std::string& guid, const std::string& section, const std::string& key); + + 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; + }; + + // 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); + + void set_virtual_value(const std::string& guid, const std::string& section, const std::string& key, const virtual_value& value); + + // 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); + + 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); + + // 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); + + // 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 new file mode 100644 index 0000000..fca5c5b --- /dev/null +++ b/src/hades2/mod_settings/sgg_gui.hpp @@ -0,0 +1,156 @@ +#pragma once + +#include +#include + +// Native option-screen GUI views limited to fields this feature reads or writes. +namespace big::mod_settings::sgg +{ + // Passed by value in a general-purpose register, not XMM. + struct Vec2 + { + float x; + float y; + }; + + static_assert(sizeof(Vec2) == 8); + + // eastl::vector stores begin, end, capacity, then its allocator. + 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; + + 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); + + // 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. Clearing it also prevents mouse-over selection. + inline constexpr std::size_t gui_component_button_selectable_offset = 0x5'51; + + // 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 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 live drawn and hit-tested widgets. mAnchor seeds new option component locations. + 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_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); + + // Native tabbed options screen. Category buttons run contiguously from +0x388 to +0x3F8. + 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 + bool m_category_focused; // +0x400 (false = option navigation, true = tab navigation) + char m_pad_d[0x07]; // 0x401 .. 0x408 + eastl_vector m_options; // +0x408 + 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 + }; + + 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_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 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/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 diff --git a/src/main.cpp b/src/main.cpp index 51731d1..4f5fb94 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" @@ -2498,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); @@ -2990,6 +2996,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)