From c76b411fd3a9a418599efdb14107583bffd26c41 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 03:34:53 -0500 Subject: [PATCH 01/17] Add native Mod Settings with shared definitions and checked persistence --- README.md | 1 + docs/MOD_SETTINGS.md | 71 ++ docs/settings-coverage.json | 330 ++++++ example_community_patch_settings_en.toml | 5 + mods/src/config.cc | 145 +-- mods/src/config.h | 8 + mods/src/config_save.cc | 132 +++ mods/src/config_save.h | 17 + mods/src/defaultconfig.h | 2 + mods/src/patches/key.cc | 27 +- mods/src/patches/key.h | 8 + mods/src/patches/mapkey.cc | 46 + mods/src/patches/mapkey.h | 12 + mods/src/patches/mission_hud.h | 8 + mods/src/patches/modifierkey.cc | 4 + mods/src/patches/modifierkey.h | 1 + .../patches/parts/fc_confirmation_reset.cc | 165 +++ .../src/patches/parts/fc_confirmation_reset.h | 9 + .../parts/forbidden_tech_confirmation.cc | 10 +- mods/src/patches/parts/hotkeys.cc | 68 +- mods/src/patches/parts/mission_hud_tweaks.cc | 224 +++- mods/src/patches/parts/mod_settings.cc | 24 + mods/src/patches/parts/pinned_ship_click.cc | 42 +- mods/src/patches/parts/runtime_config.cc | 371 ++++++ mods/src/patches/parts/zoom.cc | 318 +++-- mods/src/patches/patches.cc | 4 + mods/src/patches/runtime_config.h | 26 + mods/src/runtime_config_writer.cc | 224 ++++ mods/src/runtime_config_writer.h | 85 ++ mods/src/settings/action_setting.h | 54 + mods/src/settings/boolean_settings.h | 57 + mods/src/settings/boolean_view.h | 7 + mods/src/settings/choice_setting.h | 49 + mods/src/settings/forbidden_tech.cc | 26 + mods/src/settings/forbidden_tech.h | 7 + mods/src/settings/maintenance.h | 175 +++ mods/src/settings/mod_pages.cc | 19 + mods/src/settings/mod_pages.h | 10 + mods/src/settings/native/action_widgets.cc | 439 +++++++ mods/src/settings/native/action_widgets.h | 22 + mods/src/settings/native/interop.cc | 188 +++ mods/src/settings/native/interop.h | 51 + mods/src/settings/native/page_navigation.cc | 750 ++++++++++++ mods/src/settings/native/page_navigation.h | 53 + mods/src/settings/native/row_style.cc | 266 +++++ mods/src/settings/native/row_style.h | 31 + mods/src/settings/native/settings_search.cc | 266 +++++ mods/src/settings/native/settings_search.h | 14 + mods/src/settings/native/shortcut_popup.cc | 397 +++++++ mods/src/settings/native/text_entry.cc | 431 +++++++ mods/src/settings/native/ui_helpers.h | 74 ++ .../src/settings/native/value_widget_record.h | 38 + mods/src/settings/native/value_widgets.cc | 1033 +++++++++++++++++ mods/src/settings/native/value_widgets.h | 68 ++ mods/src/settings/native_boolean_callback.h | 83 ++ mods/src/settings/native_view_state.h | 145 +++ mods/src/settings/page_catalog.h | 283 +++++ mods/src/settings/page_sections.h | 67 ++ mods/src/settings/persisted_setting.h | 50 + mods/src/settings/setting_metadata.h | 47 + mods/src/settings/settings_search.h | 118 ++ mods/src/settings/shortcut_capture.h | 85 ++ mods/src/settings/shortcut_catalog.h | 262 +++++ mods/src/settings/shortcut_draft.h | 88 ++ mods/src/settings/shortcut_popup.h | 30 + mods/src/settings/shortcut_popup_keys.h | 33 + mods/src/settings/shortcut_settings.cc | 469 ++++++++ mods/src/settings/shortcut_settings.h | 8 + mods/src/settings/shortcut_undo.h | 82 ++ mods/src/settings/slider_setting.h | 87 ++ mods/src/settings/text_entry.h | 30 + mods/src/settings/upstream_settings.cc | 800 +++++++++++++ mods/src/settings/upstream_settings.h | 13 + mods/src/settings/value_settings.h | 337 ++++++ mods/src/settings/value_view.h | 90 ++ mods/src/settings/warp_mode.cc | 62 + mods/src/settings/warp_mode.h | 14 + mods/src/toml_editor.cc | 85 ++ mods/src/toml_editor.h | 35 + mods/src/toml_source_editor.h | 512 ++++++++ tests/boolean_settings_test.cc | 194 ++++ tests/boolean_view_test.cc | 84 ++ tests/choice_setting_test.cc | 93 ++ tests/config_save_failure_test.cc | 60 + tests/config_save_test.cc | 74 ++ tests/native_boolean_callback_test.cc | 93 ++ tests/page_catalog_test.cc | 215 ++++ tests/page_sections_test.cc | 91 ++ tests/persisted_setting_test.cc | 73 ++ tests/run-settings.ps1 | 122 ++ tests/runtime_config_fixture.h | 82 ++ tests/runtime_config_persistence_test.cc | 95 ++ tests/runtime_config_test.cc | 146 +++ tests/runtime_config_writer_test.cc | 280 +++++ tests/settings_maintenance_test.cc | 137 +++ tests/settings_metadata_test.cc | 81 ++ tests/settings_search_test.cc | 72 ++ tests/shortcut_editor_test.cc | 343 ++++++ tests/shortcut_hint_cache.cc | 22 + tests/slider_setting_test.cc | 116 ++ tests/test_settings_coverage.py | 123 ++ tests/toml_editor_test.cc | 121 ++ tests/upstream_settings_fixture/file.h | 16 + .../upstream_settings_fixture/spdlog/spdlog.h | 6 + tests/upstream_settings_test.cc | 763 ++++++++++++ 105 files changed, 14088 insertions(+), 241 deletions(-) create mode 100644 docs/MOD_SETTINGS.md create mode 100644 docs/settings-coverage.json create mode 100644 mods/src/config_save.cc create mode 100644 mods/src/config_save.h create mode 100644 mods/src/patches/mission_hud.h create mode 100644 mods/src/patches/parts/fc_confirmation_reset.cc create mode 100644 mods/src/patches/parts/fc_confirmation_reset.h create mode 100644 mods/src/patches/parts/mod_settings.cc create mode 100644 mods/src/patches/parts/runtime_config.cc create mode 100644 mods/src/patches/runtime_config.h create mode 100644 mods/src/runtime_config_writer.cc create mode 100644 mods/src/runtime_config_writer.h create mode 100644 mods/src/settings/action_setting.h create mode 100644 mods/src/settings/boolean_settings.h create mode 100644 mods/src/settings/boolean_view.h create mode 100644 mods/src/settings/choice_setting.h create mode 100644 mods/src/settings/forbidden_tech.cc create mode 100644 mods/src/settings/forbidden_tech.h create mode 100644 mods/src/settings/maintenance.h create mode 100644 mods/src/settings/mod_pages.cc create mode 100644 mods/src/settings/mod_pages.h create mode 100644 mods/src/settings/native/action_widgets.cc create mode 100644 mods/src/settings/native/action_widgets.h create mode 100644 mods/src/settings/native/interop.cc create mode 100644 mods/src/settings/native/interop.h create mode 100644 mods/src/settings/native/page_navigation.cc create mode 100644 mods/src/settings/native/page_navigation.h create mode 100644 mods/src/settings/native/row_style.cc create mode 100644 mods/src/settings/native/row_style.h create mode 100644 mods/src/settings/native/settings_search.cc create mode 100644 mods/src/settings/native/settings_search.h create mode 100644 mods/src/settings/native/shortcut_popup.cc create mode 100644 mods/src/settings/native/text_entry.cc create mode 100644 mods/src/settings/native/ui_helpers.h create mode 100644 mods/src/settings/native/value_widget_record.h create mode 100644 mods/src/settings/native/value_widgets.cc create mode 100644 mods/src/settings/native/value_widgets.h create mode 100644 mods/src/settings/native_boolean_callback.h create mode 100644 mods/src/settings/native_view_state.h create mode 100644 mods/src/settings/page_catalog.h create mode 100644 mods/src/settings/page_sections.h create mode 100644 mods/src/settings/persisted_setting.h create mode 100644 mods/src/settings/setting_metadata.h create mode 100644 mods/src/settings/settings_search.h create mode 100644 mods/src/settings/shortcut_capture.h create mode 100644 mods/src/settings/shortcut_catalog.h create mode 100644 mods/src/settings/shortcut_draft.h create mode 100644 mods/src/settings/shortcut_popup.h create mode 100644 mods/src/settings/shortcut_popup_keys.h create mode 100644 mods/src/settings/shortcut_settings.cc create mode 100644 mods/src/settings/shortcut_settings.h create mode 100644 mods/src/settings/shortcut_undo.h create mode 100644 mods/src/settings/slider_setting.h create mode 100644 mods/src/settings/text_entry.h create mode 100644 mods/src/settings/upstream_settings.cc create mode 100644 mods/src/settings/upstream_settings.h create mode 100644 mods/src/settings/value_settings.h create mode 100644 mods/src/settings/value_view.h create mode 100644 mods/src/settings/warp_mode.cc create mode 100644 mods/src/settings/warp_mode.h create mode 100644 mods/src/toml_editor.cc create mode 100644 mods/src/toml_editor.h create mode 100644 mods/src/toml_source_editor.h create mode 100644 tests/boolean_settings_test.cc create mode 100644 tests/boolean_view_test.cc create mode 100644 tests/choice_setting_test.cc create mode 100644 tests/config_save_failure_test.cc create mode 100644 tests/config_save_test.cc create mode 100644 tests/native_boolean_callback_test.cc create mode 100644 tests/page_catalog_test.cc create mode 100644 tests/page_sections_test.cc create mode 100644 tests/persisted_setting_test.cc create mode 100644 tests/run-settings.ps1 create mode 100644 tests/runtime_config_fixture.h create mode 100644 tests/runtime_config_persistence_test.cc create mode 100644 tests/runtime_config_test.cc create mode 100644 tests/runtime_config_writer_test.cc create mode 100644 tests/settings_maintenance_test.cc create mode 100644 tests/settings_metadata_test.cc create mode 100644 tests/settings_search_test.cc create mode 100644 tests/shortcut_editor_test.cc create mode 100644 tests/slider_setting_test.cc create mode 100644 tests/test_settings_coverage.py create mode 100644 tests/toml_editor_test.cc create mode 100644 tests/upstream_settings_fixture/file.h create mode 100644 tests/upstream_settings_fixture/spdlog/spdlog.h create mode 100644 tests/upstream_settings_test.cc diff --git a/README.md b/README.md index dd912ed6d..0f1deef8a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This project is maintained solely at my own cost of time, energy and money. Any ## Features +- Change preferences and shortcuts in the game's **Settings → Mod Settings**; see [in-game settings](docs/MOD_SETTINGS.md). - Set system UI scale + adjustment factor - Set viewer UI scale - Set system zoom diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md new file mode 100644 index 000000000..393bc0cdd --- /dev/null +++ b/docs/MOD_SETTINGS.md @@ -0,0 +1,71 @@ +# In-game Mod Settings + +Open the game's Settings and choose **Mod Settings** to change Community Mod preferences. Pages group controls by task: camera, interface scale, travel, cargo, claims, officers, chat, notifications, HUD buttons, and fleet labels. Search accepts labels and canonical TOML names such as `ui.show_player_title`. + +The editor uses the same configuration values and defaults as the mod's TOML configuration. Opening a page or reading a value does not save, normalize, or reset it. TOML remains available for every setting; **Advanced & Configuration** shows the active file's location. + +## Applying and saving + +Ordinary controls apply to the current session and save automatically. Each control reports its own save state. A failed save or an external edit conflict remains visible; another successful save does not clear it. Resolve a conflicting external edit before retrying. Unrelated TOML values and comments are preserved. + +Some controls take effect when their screen is reopened. Restart controls retain the requested preference separately from the active value, so a saved restart change is not presented as already active. Examples include native-game shortcut mode, window setup, loading assets, cargo formatting, officer sort initialization, and chat channel suppression. + +Open a page's **Defaults & Help** to read setting details, restore one default, retry a failed save, or review a page reset. Reset uses the default declared by the setting. A page reset applies each available setting once, including a number shown both as an exact entry and a convenience slider. The confirmation reports unavailable settings it will skip. If an included setting changes while confirmation is open, confirm again from a fresh dialog. Changes save individually; the page reset is not a transactional file batch. It leaves unrelated settings and other pages alone. + +## Numbers and lists + +Use **Edit** for an exact numeric value. Convenience sliders have useful ranges; an existing custom value outside that range remains visible in the exact editor and is preserved until explicitly changed. Numeric entries reject invalid syntax, non-finite numbers, and values outside a setting's valid domain. Integer controls require whole numbers. Units and setting-specific limits appear with the control. + +Ship filter lists accept comma-separated names using the same matching rules as TOML. Instant warp filters accept `*`; pinned ships require names. Faction and banner lists validate names against the startup parser rather than silently discarding unknown entries. Clear a list explicitly to remove its entries. + +The loading image control accepts a local file path. Empty selects the embedded fallback. Selecting a path does not copy, move, or modify the image file, and the loading asset changes require restart. + +## Shortcuts + +The shortcut editor covers the mod's registered actions, including actions currently bound to `NONE`. Each action supports multiple alternatives, recording a replacement or additional binding, removing a binding, restoring defaults, and undoing the most recent removal/default restoration. + +Recording waits for keys to be released. Confirm the proposed binding to apply it, or cancel with Escape. Losing application focus cancels recording. Gameplay shortcuts remain suppressed while an editor owns keyboard input and until held keys have been released. + +Overlap warnings help identify shared keys. Some actions intentionally share bindings in different contexts, so warnings do not remove another binding automatically. Unsupported native adapters leave the relevant action unavailable for that session. Changing to or from the game's own shortcut mode requires restart. + +## Advanced settings kept in TOML + +These categories are explained in the Advanced page rather than exposed as ordinary live controls: + +| Category | Reason | +| --- | --- | +| `[patches]` | Emergency hook-install compatibility switches require restart. They are separate from feature preferences. | +| `[sync]` and `[sync.targets.]` | Dynamic service targets, credentials, connection options, and worker configuration require a separate secret-safe editor. Token values are never shown in the settings UI. Restart after editing. | +| `audio.trace_events`, `audio.disabled_events` | Diagnostic event tracing and arbitrary internal event-name filters remain advanced configuration. | +| `[config]` endpoint overrides | Configuration/distribution infrastructure is configured before the in-game editor starts. Restart after editing. | +| `control.enable_experimental`, `graphics.system_pan_momentum` | Legacy parser inputs with no current gameplay consumer. The functional pan-glide option is `graphics.system_pan_momentum_falloff`. | + +`graphics.free_resize` and `graphics.borderless_fullscreen` are Windows options. The macOS editor explains their platform scope instead of offering controls that cannot affect that client. + +See the English example configuration for accepted advanced values. Deprecated aliases remain startup compatibility inputs; the editor writes canonical keys. Old queue feature switches and constants that the parser no longer reads are not extra controls. + +## Coverage and maintenance + +[settings-coverage.json](settings-coverage.json) inventories all 154 canonical configuration keys and 105 canonical shortcut actions. The feature includes 89 player-setting controls. The remaining 26 named non-hook options and 39 hook-install switches have explicit TOML-only treatment. Dynamic sync target fields follow the same sync policy. The emergency switches `patches.nativesettingshooks` and `patches.runtimeconfighooks` independently control native settings and the shared persistence runtime. + +`settings/upstream_settings.cc` defines controls with their canonical key, default, help, unit, application timing, availability, validation, and persistence serializer together. Shared instant-warp and Forbidden Tech models retain their own definitions so native settings and the mod page use the same state. Live shortcut and pin actions reuse the same field serializer through `PersistCurrentSetting`; they do not maintain a second save whitelist. A new option should receive either an in-game definition or an explicit inventory exclusion. A new shortcut should appear through the registered action catalog. + +Both Windows and macOS use the shared models, persistence, and page definitions. Platform-specific code handles native UI and input. A successful build does not establish native hook compatibility or player smoke coverage for another client build or architecture. + +## Local validation and player checks + +Run `pwsh -NoProfile -File tests/run-settings.ps1` for the standalone models, save/reload, conflicts, shutdown and file-permission fixtures on Windows. Run `python -m unittest discover -s tests -p test_settings_coverage.py` to check the catalog inventory. These are local development checks and are not added to CI. + +For each candidate artifact, record its source commit, client build, platform and architecture. macOS ARM64 and x86_64 require separate build and native-fit evidence; results on one architecture do not qualify the other. The native adapter checks loaded Mac method boundaries before installing detours and leaves unsupported controls unavailable. + +Use reversible preferences for these player checks; no claim or purchase is required: + +1. Open Mod Settings, visit each category, search a label and a TOML key, follow a result, and use Back. Close and reopen Settings; verify the game's own pages still work. +2. Toggle a live option, reopen its page, then restart the client and check the saved value. Confirm native Fleet Commander preferences still use their existing game storage. +3. Enter an exact number, an invalid number, and a valid value outside its convenience slider range. Cancel an edit and verify no change or save occurred. Check interface scale at the smallest supported window size. +4. Change a restart preference and verify it shows pending restart while the active behavior stays unchanged. Restore it afterward. +5. Review a single default and a page reset. Cancel each once, then confirm with reversible preferences. Check a conditional slider and its exact editor reset only once. +6. Edit a ship list, faction list, banner list, and optional loading-image path. Check empty values, invalid entries, and a path containing spaces or non-ASCII characters. +7. Record, cancel, replace and remove a shortcut; restore its default and undo. Include an initially `NONE` action and a modifier combination. Verify no gameplay hotkey fires while typing, recording, or releasing held keys. Use Command combinations on Mac. +8. Switch HUD buttons through Auto, Always and Never and restore Auto. Switch fleet labels through Native and overrides, change thresholds, then change system/scene and reopen Settings. +9. With a copy of the config, exercise a read-only file and an external edit to the same key. Check the failure stays visible after saving another key. Resolve the conflict, retry that key, and verify unrelated comments and values survive. Quit immediately after an edit and check it survives the next launch. diff --git a/docs/settings-coverage.json b/docs/settings-coverage.json new file mode 100644 index 000000000..3abee2173 --- /dev/null +++ b/docs/settings-coverage.json @@ -0,0 +1,330 @@ +{ + "schema": 1, + "baseline": "f89bc47e", + "scope": "Canonical settings read by Config::Load, including the settings feature itself; compatibility aliases and generated parsed diagnostics are not separate controls.", + "settings": [ + {"key": "audio.alert_armada_battle_lost", "control": "choice", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "audio.alert_armada_battle_won", "control": "choice", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "audio.alert_armada_created", "control": "choice", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "audio.alert_defeat", "control": "choice", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "audio.alert_victory", "control": "choice", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "audio.disabled_events", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "audio"}, + {"key": "audio.trace_events", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "audio"}, + {"key": "buffs.use_out_of_dock_power", "control": "boolean", "page": "officers", "application": "live", "treatment": "in_game"}, + {"key": "config.assets_url_override", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "config"}, + {"key": "config.settings_url", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "config"}, + {"key": "control.enable_experimental", "treatment": "toml_only", "application": "restart", "page": "advanced", "policy": "legacy_experimental"}, + {"key": "control.hotkeys_enabled", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, + {"key": "control.hotkeys_extended", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, + {"key": "control.kirshara_fixes", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, + {"key": "control.primary_requires_visible_target", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, + {"key": "control.queue_enabled", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, + {"key": "control.select_timer", "control": "number", "page": "controls", "application": "live", "treatment": "in_game"}, + {"key": "control.use_scopely_hotkeys", "control": "boolean", "page": "controls", "application": "restart", "treatment": "in_game"}, + {"key": "graphics.allow_cursor", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "graphics.borderless_fullscreen", "control": "boolean", "page": "interface", "application": "restart", "treatment": "in_game", "platforms": ["windows"], "other_platforms": "Explanatory advanced row; native Windows-only functionality is not offered on macOS."}, + {"key": "graphics.default_system_zoom", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.fr_scale", "control": "number", "page": "interface", "application": "restart", "treatment": "in_game"}, + {"key": "graphics.free_resize", "control": "boolean", "page": "interface", "application": "restart", "treatment": "in_game", "platforms": ["windows"], "other_platforms": "Explanatory advanced row; native Windows-only functionality is not offered on macOS."}, + {"key": "graphics.galactic_anomaly_timer", "control": "boolean", "page": "travel", "application": "live", "treatment": "in_game"}, + {"key": "graphics.haven_zoom", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.keyboard_zoom_speed", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.loader_enabled", "control": "boolean", "page": "loading", "application": "restart", "treatment": "in_game"}, + {"key": "graphics.loader_image", "control": "path", "page": "loading", "application": "restart", "treatment": "in_game"}, + {"key": "graphics.loader_logo_scale", "control": "number", "page": "loading", "application": "restart", "treatment": "in_game"}, + {"key": "graphics.loader_tip_enabled", "control": "boolean", "page": "loading", "application": "restart", "treatment": "in_game"}, + {"key": "graphics.loader_transition", "control": "boolean", "page": "loading", "application": "restart", "treatment": "in_game"}, + {"key": "graphics.loader_transition_black", "control": "boolean", "page": "loading", "application": "restart", "treatment": "in_game"}, + {"key": "graphics.system_pan_momentum", "treatment": "toml_only", "application": "restart", "page": "advanced", "policy": "legacy_experimental"}, + {"key": "graphics.system_pan_momentum_falloff", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.system_zoom_preset_1", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.system_zoom_preset_2", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.system_zoom_preset_3", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.system_zoom_preset_4", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.system_zoom_preset_5", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.transition_time", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.ui_scale", "control": "number", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "graphics.ui_scale_ship", "control": "number", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "graphics.ui_scale_step", "control": "number", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "graphics.ui_scale_viewer", "control": "number", "page": "interface", "application": "reopen_view", "treatment": "in_game"}, + {"key": "graphics.use_presets_as_default", "control": "boolean", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.zoom", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.zoom_label_non_player_detail", "control": "choice", "page": "labels", "application": "live", "treatment": "in_game"}, + {"key": "graphics.zoom_label_non_player_threshold", "control": "number", "page": "labels", "application": "live", "treatment": "in_game"}, + {"key": "graphics.zoom_label_player_detail", "control": "choice", "page": "labels", "application": "live", "treatment": "in_game"}, + {"key": "graphics.zoom_label_player_threshold", "control": "number", "page": "labels", "application": "live", "treatment": "in_game"}, + {"key": "patches.actionqueuerecoveryhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.artifactexchangehooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.audioeventhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.bufffixhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.cargoformathooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.chatpatches", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.chattitledisplayhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.dailyfactionbulkclaimhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.doubleclickassignshiphooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.forbiddentechconfirmhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.freeresizehooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.galacticanomalytimerhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.game_version", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.giftsbulkclaimhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.havenhistoryhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.havenzoomhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.hotkeyhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.instantcargocounterhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.loadingscreenhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.loadingtiphooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.miscpatches", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.missionhudtweakshooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.nativesettingshooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.objecttracker", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.officersorthooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.panhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.pinnedshipclicktoggle", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.pinnedshiphooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.queueaddressguardhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.revealconfirmhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.runtimeconfighooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.syncpatches", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.tempcrashfixes", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.testpatches", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.thinqueueprotectionhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.toastbannerhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.transitionscreenhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.uiscalehooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "patches.zoomhooks", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "patches"}, + {"key": "sync.away_assignments", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.battlelogs", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.buffs", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.buildings", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.debug", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.haven", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.inventory", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.jobs", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.logging", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.missions", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.officers", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.proxy", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.research", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.resolver_cache_ttl", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.resources", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.ships", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.slots", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.tech", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.traits", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "sync.verify_ssl", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "sync"}, + {"key": "ui.always_skip_reveal_sequence", "control": "boolean", "page": "claims", "application": "live", "treatment": "in_game"}, + {"key": "ui.arrow_keys_to_select_ship", "control": "boolean", "page": "officers", "application": "live", "treatment": "in_game"}, + {"key": "ui.auto_confirm_ft_upgrade", "control": "boolean", "page": "claims", "application": "live", "treatment": "in_game"}, + {"key": "ui.auto_confirm_instant_warp", "control": "choice", "page": "travel", "application": "live", "treatment": "in_game"}, + {"key": "ui.auto_confirm_reveal_ability", "control": "boolean", "page": "claims", "application": "live", "treatment": "in_game"}, + {"key": "ui.auto_open_bulk_claim_flyout", "control": "boolean", "page": "claims", "application": "live", "treatment": "in_game"}, + {"key": "ui.cargo_significant_decimals", "control": "number", "page": "previews", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.daily_bulk_claim_factions", "control": "list", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.daily_bulk_claim_toggle_default_on", "control": "boolean", "page": "claims", "application": "restart", "treatment": "in_game"}, + {"key": "ui.disable_escape_exit", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "ui.disable_escape_exit_timer", "control": "number", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "ui.disable_exchange_all", "control": "boolean", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.disable_galaxy_chat", "control": "boolean", "page": "chat", "application": "restart", "treatment": "in_game"}, + {"key": "ui.disable_move_keys", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "ui.disable_preview_locate", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, + {"key": "ui.disable_preview_recall", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, + {"key": "ui.disable_toast_banners", "control": "boolean", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "ui.disable_veil_chat", "control": "boolean", "page": "chat", "application": "restart", "treatment": "in_game"}, + {"key": "ui.disabled_banner_types", "control": "list", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "ui.dock_syncmanagement", "control": "boolean", "page": "officers", "application": "live", "treatment": "in_game"}, + {"key": "ui.double_click_to_assign_ship", "control": "boolean", "page": "officers", "application": "live", "treatment": "in_game"}, + {"key": "ui.extend_chest_purchase_max", "control": "number", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.extend_donation_max", "control": "number", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.extend_donation_slider", "control": "boolean", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.focus_search", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "ui.format_cargo_values", "control": "boolean", "page": "previews", "application": "restart", "treatment": "in_game"}, + {"key": "ui.hud_field_training", "control": "choice", "page": "hud", "application": "live", "treatment": "in_game"}, + {"key": "ui.hud_missions", "control": "choice", "page": "hud", "application": "live", "treatment": "in_game"}, + {"key": "ui.hud_outposts", "control": "choice", "page": "hud", "application": "live", "treatment": "in_game"}, + {"key": "ui.hud_q_trials", "control": "choice", "page": "hud", "application": "live", "treatment": "in_game"}, + {"key": "ui.instant_cargo_counter", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, + {"key": "ui.instant_warp_always_ask", "control": "list", "page": "travel.filters", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.instant_warp_auto_jump", "control": "list", "page": "travel.filters", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.instant_warp_auto_warp", "control": "list", "page": "travel.filters", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.notify_banner_types", "control": "list", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "ui.officer_sort", "control": "boolean", "page": "officers", "application": "restart", "treatment": "in_game"}, + {"key": "ui.only_show_first_popup", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "ui.pinned_ships", "control": "list", "page": "travel.filters", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.reverse_haven_history", "control": "boolean", "page": "officers", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.show_armada_cargo", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, + {"key": "ui.show_cargo_default", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, + {"key": "ui.show_hostile_cargo", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, + {"key": "ui.show_player_cargo", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, + {"key": "ui.show_player_title", "control": "boolean", "page": "chat", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.show_station_cargo", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"} + ], + "shortcuts": [ + "shortcuts.move_up", + "shortcuts.move_down", + "shortcuts.move_left", + "shortcuts.move_right", + "shortcuts.set_hotkeys_disabled", + "shortcuts.set_hotkeys_enabled", + "shortcuts.select_chatalliance", + "shortcuts.select_chatglobal", + "shortcuts.select_chatprivate", + "shortcuts.quit", + "shortcuts.select_ship1", + "shortcuts.select_ship2", + "shortcuts.select_ship3", + "shortcuts.select_ship4", + "shortcuts.select_ship5", + "shortcuts.select_ship6", + "shortcuts.select_ship7", + "shortcuts.select_ship8", + "shortcuts.select_current", + "shortcuts.focus_search", + "shortcuts.action_primary", + "shortcuts.action_secondary", + "shortcuts.action_queue", + "shortcuts.action_queue_clear", + "shortcuts.action_view", + "shortcuts.action_recall", + "shortcuts.action_recall_cancel", + "shortcuts.action_repair", + "shortcuts.show_chat", + "shortcuts.show_chatside1", + "shortcuts.show_chatside2", + "shortcuts.show_galaxy", + "shortcuts.show_galaxy_native", + "shortcuts.show_system", + "shortcuts.zoom_preset1", + "shortcuts.zoom_preset2", + "shortcuts.zoom_preset3", + "shortcuts.zoom_preset4", + "shortcuts.zoom_preset5", + "shortcuts.zoom_in", + "shortcuts.zoom_out", + "shortcuts.zoom_max", + "shortcuts.zoom_min", + "shortcuts.zoom_reset", + "shortcuts.ui_scaleup", + "shortcuts.ui_scaledown", + "shortcuts.ui_scaleshipup", + "shortcuts.ui_scaleshipdown", + "shortcuts.ui_scaleviewerup", + "shortcuts.ui_scaleviewerdown", + "shortcuts.log_debug", + "shortcuts.log_trace", + "shortcuts.log_info", + "shortcuts.log_warn", + "shortcuts.log_error", + "shortcuts.log_off", + "shortcuts.restart", + "shortcuts.show_awayteam", + "shortcuts.show_gifts", + "shortcuts.show_artifacts", + "shortcuts.show_commander", + "shortcuts.show_daily", + "shortcuts.show_events", + "shortcuts.show_events_native", + "shortcuts.show_exocomp", + "shortcuts.show_factions", + "shortcuts.show_inventory", + "shortcuts.show_missions", + "shortcuts.show_research", + "shortcuts.show_scrapyard", + "shortcuts.show_settings", + "shortcuts.toggle_shortcut_hints", + "shortcuts.show_officers", + "shortcuts.show_officerpresets", + "shortcuts.show_officerswap", + "shortcuts.show_qtrials", + "shortcuts.show_refinery", + "shortcuts.show_ships", + "shortcuts.show_shipconstruction", + "shortcuts.show_shipswap", + "shortcuts.show_shields", + "shortcuts.show_battlelogs", + "shortcuts.show_stationexterior", + "shortcuts.show_stationinterior", + "shortcuts.show_haven", + "shortcuts.toggle_queue", + "shortcuts.toggle_instant_warp", + "shortcuts.show_alliance", + "shortcuts.show_alliance_help", + "shortcuts.show_alliance_armada", + "shortcuts.show_bookmarks", + "shortcuts.show_lookup", + "shortcuts.set_zoom_preset1", + "shortcuts.set_zoom_preset2", + "shortcuts.set_zoom_preset3", + "shortcuts.set_zoom_preset4", + "shortcuts.set_zoom_preset5", + "shortcuts.set_zoom_default", + "shortcuts.toggle_preview_locate", + "shortcuts.toggle_preview_recall", + "shortcuts.toggle_cargo_default", + "shortcuts.toggle_cargo_player", + "shortcuts.toggle_cargo_station", + "shortcuts.toggle_cargo_hostile", + "shortcuts.toggle_cargo_armada" + ], + "dynamic_settings": [ + "sync.targets..url", + "sync.targets..token", + "sync.targets..proxy", + "sync.targets..verify_ssl", + "sync.targets..away_assignments", + "sync.targets..battlelogs", + "sync.targets..buffs", + "sync.targets..buildings", + "sync.targets..haven", + "sync.targets..inventory", + "sync.targets..jobs", + "sync.targets..missions", + "sync.targets..officers", + "sync.targets..research", + "sync.targets..resources", + "sync.targets..ships", + "sync.targets..slots", + "sync.targets..tech", + "sync.targets..traits" + ], + "compatibility_alias_policy": "Read existing aliases at startup, but show and persist only canonical keys. Do not add controls for obsolete queue flags or ignored defaults.", + "excluded_downstream_features": [ + "claim diagnostics and recovery", + "Kirshara science traces", + "custom audio files and coalescing", + "fleet and incoming-attack notifications", + "galaxy overlays and label modes", + "Haven water visibility", + "OPC highlight and ETA", + "ship technology indicators", + "ship hotkey badges", + "officer preset reordering", + "keep-base-card behavior", + "keyboard layout overrides", + "orbit cameras", + "developer console" + ], + "notes": [ + "Page reset/retry is deduplicated by canonical TOML key.", + "Convenience slider ranges do not constrain existing TOML values; exact editors show custom values.", + "Read-only browsing neither persists nor normalizes settings.", + "Restart controls retain requested values separately from active process values." + ], + "toml_only_policies": { + "audio": "Advanced event tracing or arbitrary event-name mute filter; documented in the English TOML example.", + "config": "Advanced configuration/distribution endpoint override; not an ordinary player preference.", + "patches": "Emergency hook-install compatibility switch. Restart is required; not a live feature preference.", + "sync": "Network target configuration and diagnostics remain with the secret-bearing sync configuration. Active workers may snapshot these values; no credentials are rendered.", + "legacy_experimental": "Retained startup compatibility inputs with no current gameplay consumer. Do not imply an in-game control changes behavior." + }, + "shortcuts_policy": { + "treatment": "in_game", + "application": "live", + "page": "shortcuts", + "condition": "Requires mod shortcuts and their native adapters to be installed for this session. Native-game shortcut mode requires restart to change." + }, + "dynamic_settings_policy": { + "treatment": "toml_only", + "application": "restart", + "reason": "Dynamic network target and credential editor is outside the in-game preference editor; token values are never displayed." + } +} diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 88671a376..b0034585a 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -274,6 +274,11 @@ haven_zoom = 4000.0 # <[=====================================================]> [patches] +# Native Mod Settings pages and the checked background TOML saver. These are +# emergency compatibility switches; changing either requires a client restart. +# Normal feature preferences remain in their own sections and in Mod Settings. +nativesettingshooks = true +runtimeconfighooks = true # Subgroup: Leave Well Alone # -------------------------- diff --git a/mods/src/config.cc b/mods/src/config.cc index dbd0119d7..59cc0d1e6 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1,7 +1,10 @@ #include "config.h" +#include "config_save.h" #include "file.h" #include "patches/mapkey.h" +#include "patches/runtime_config.h" #include "prime/KeyCode.h" +#include "settings/upstream_settings.h" #include "ship_name_match.h" #include "str_utils.h" #include "version.h" @@ -18,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -123,10 +127,8 @@ Config::Config() void Config::Save(const toml::table& config, const std::string_view filename, bool apply_warning) { - std::ofstream config_file; - auto config_path = File::MakePath(filename, true); - config_file.open(config_path); + std::ostringstream config_file; if (apply_warning) { char defaultFile[255], configFile[255]; @@ -148,8 +150,7 @@ void Config::Save(const toml::table& config, const std::string_view filename, bo config_file << "#######################################################################\n\n"; } - config_file << config; - config_file.close(); + SaveConfigDocument(config, config_path, config_file.str()); } Config& Config::Get() @@ -259,6 +260,7 @@ void Config::AdjustUiScale(bool scaleUp) auto new_scale = this->ui_scale + scale_factor; this->ui_scale = std::clamp(new_scale, 0.1f, 2.0f); + mod_settings::PersistCurrentSetting("graphics", "ui_scale"); auto dpi = Config::RefreshDPI(); spdlog::info("UI has been scaled {}, was {}, now {} (unclamped {}) @ {} DPI Scaling", (scaleUp ? "UP" : "DOWN"), old_scale, this->ui_scale, new_scale, dpi); @@ -273,6 +275,7 @@ void Config::AdjustUiViewerScale(bool scaleUp) auto new_scale = this->ui_scale_viewer + (scale_factor * 0.25f); this->ui_scale_viewer = std::clamp(new_scale, 0.1f, 2.0f); + mod_settings::PersistCurrentSetting("graphics", "ui_scale_viewer"); spdlog::info("UI Viewer has been scaled {}, was {}, now {} (unclamped {})", (scaleUp ? "UP" : "DOWN"), old_scale, this->ui_scale_viewer, new_scale); } @@ -288,6 +291,7 @@ void Config::AdjustUiShipScale(bool scaleUp) spdlog::info("System ship models have been scaled {}, was {}, now {} (unclamped {})", (scaleUp ? "UP" : "DOWN"), old_scale, this->ui_scale_ship, new_scale); ApplyUiShipScaleToLoadedShips(old_scale, this->ui_scale_ship); + mod_settings::PersistCurrentSetting("graphics", "ui_scale_ship"); } inline std::string mask_token(const std::string& token) @@ -558,21 +562,25 @@ float get_fleet_label_zoom_threshold(toml::table& config, toml::table& new_confi return threshold; } -void parse_ship_filter(std::string_view value, std::vector& names, bool& match_all) +bool ParseShipFilter(std::string_view value, std::vector& names, bool& match_all) { names.clear(); match_all = false; if (AsciiStrToUpper(StripAsciiWhitespace(value)) == "*") { match_all = true; - return; + return true; } + bool valid = true; for (const auto& token : StrSplit(std::string(value), ',')) { auto stripped = StripAsciiWhitespace(token); if (stripped.empty()) continue; - if (auto normalized = ShipNameMatch::NormalizeKey(stripped); !normalized.empty()) { + if (auto normalized = ShipNameMatch::NormalizeKey(stripped); !normalized.empty() && stripped != "*") { names.emplace_back(std::move(normalized)); + } else { + valid = false; } } + return valid; } void read_instant_warp_filter(toml::table& config, toml::table& new_config, std::string_view key, @@ -580,7 +588,7 @@ void read_instant_warp_filter(toml::table& config, toml::table& new_config, std: std::string_view default_value, bool write_log) { const auto value = config["ui"][key].value().value_or(std::string(default_value)); - parse_ship_filter(value, names, match_all); + ParseShipFilter(value, names, match_all); new_config.emplace("ui", toml::table()); new_config["ui"].as_table()->insert_or_assign(std::string(key), std::string(value)); @@ -590,11 +598,12 @@ void read_instant_warp_filter(toml::table& config, toml::table& new_config, std: } } -void parse_faction_filter(std::string_view value, std::vector& factions) +bool ParseFactionFilter(std::string_view value, std::vector& factions) { static constexpr std::array kKnownFactions{"federation", "klingon", "romulan"}; factions.clear(); + bool valid = true; for (const auto& token : StrSplit(std::string(value), ',')) { const auto stripped = StripAsciiWhitespace(token); if (stripped.empty()) continue; @@ -602,9 +611,7 @@ void parse_faction_filter(std::string_view value, std::vector& fact auto lowered = AsciiStrToLower(stripped); if (std::ranges::find(kKnownFactions, lowered) == kKnownFactions.end()) { - spdlog::warn("Unrecognised faction '{}' in daily_bulk_claim_factions; expected one of: federation, klingon, " - "romulan. Ignoring.", - stripped); + valid = false; continue; } @@ -612,6 +619,40 @@ void parse_faction_filter(std::string_view value, std::vector& fact factions.emplace_back(std::move(lowered)); } } + return valid; +} + +bool ParseBannerFilter(std::string_view value, std::vector& types) +{ + types.clear(); + bool valid = true; + for (const auto& token : StrSplit(std::string(value), ',')) { + const auto name = AsciiStrToUpper(StripAsciiWhitespace(token)); + if (name.empty()) + continue; + bool found = false; + for (const auto& [key, type] : bannerTypes) { + if (name == "ALL" || name == AsciiStrToUpper(key)) { + if (std::ranges::find(types, type) == types.end()) + types.push_back(type); + found = true; + } + } + valid &= found; + } + return valid; +} + +std::string FormatBannerFilter(const std::vector& types) +{ + std::string result; + for (const auto& [key, type] : bannerTypes) + if (std::ranges::find(types, type) != types.end()) { + if (!result.empty()) + result += ", "; + result += key; + } + return result; } void read_daily_bulk_claim_factions(toml::table& config, toml::table& new_config, std::vector& factions, @@ -619,7 +660,9 @@ void read_daily_bulk_claim_factions(toml::table& config, toml::table& new_config { const auto value = config["ui"]["daily_bulk_claim_factions"].value().value_or(std::string(default_value)); - parse_faction_filter(value, factions); + if (!ParseFactionFilter(value, factions)) + spdlog::warn("Unrecognised faction in daily_bulk_claim_factions; expected federation, klingon or romulan. Ignoring " + "unknown names."); new_config.emplace("ui", toml::table()); new_config["ui"].as_table()->insert_or_assign("daily_bulk_claim_factions", std::string(value)); @@ -737,6 +780,7 @@ void set_shortcut_noop(toml::node_view sectionTable, toml::node_view void parse_config_shortcut_value(toml::table& new_config, std::string_view item, GameFunction gameFunction, std::string_view default_value, const ShortcutConfigValue& shortcut_value) { + MapKey::RegisterAction(gameFunction, item, default_value); auto section = "shortcuts"; auto source = "shortcuts_source"; @@ -961,6 +1005,10 @@ void Config::Load() this->installPanHooks = get_config_or_default(config, parsed, "patches", "panhooks", DCP::panhooks, write_config); this->installHotkeyHooks = get_config_or_default(config, parsed, "patches", "hotkeyhooks", DCP::hotkeyhooks, write_config); + this->installNativeSettings = + get_config_or_default(config, parsed, "patches", "nativesettingshooks", DCP::nativesettingshooks, write_config); + this->installRuntimeConfigHooks = + get_config_or_default(config, parsed, "patches", "runtimeconfighooks", DCP::runtimeconfighooks, write_config); this->installFreeResizeHooks = get_config_or_default(config, parsed, "patches", "freeresizehooks", DCP::freeresizehooks, write_config); this->installTempCrashFixes = @@ -1321,66 +1369,17 @@ void Config::Load() this->loader_tip_enabled = get_config_or_default(config, parsed, "graphics", "loader_tip_enabled", DCG::loader_tip_enabled, write_log); - std::vector types = StrSplit(disabled_banner_types_str, ','); + if (!ParseBannerFilter(disabled_banner_types_str, this->disabled_banner_types)) + spdlog::warn("Unknown disabled_banner_types ignored"); + parsed["ui"].as_table()->insert_or_assign("disabled_banner_types", FormatBannerFilter(this->disabled_banner_types)); - spdlog::debug(""); + const auto notify_banner_types_str = get_config_or_default(config, parsed, "ui", "notify_banner_types", + DCU::notify_banner_types, write_log); + if (!ParseBannerFilter(notify_banner_types_str, this->notify_banner_types)) + spdlog::warn("Unknown notify_banner_types ignored"); + parsed["ui"].as_table()->insert_or_assign("notify_banner_types", FormatBannerFilter(this->notify_banner_types)); - std::string bannerString; std::stringstream message; - message << "Parsing banner strings"; - - spdlog::debug(message.str()); - - for (const auto& [key, value] : bannerTypes) { - auto upper_key = AsciiStrToUpper(key); - - for (const std::string_view _type : types) { - auto stripped_type = StripLeadingAsciiWhitespace(_type); - auto upper_type = AsciiStrToUpper(stripped_type); - - if ("ALL" == upper_type || upper_key == upper_type) { - this->disabled_banner_types.emplace_back(value); - if (!bannerString.empty()) { - bannerString.append(", "); - } - bannerString.append(key); - } - } - } - - message.str(""); - message << "Final disabledbanner types: " << bannerString; - spdlog::debug(message.str()); - - parsed["ui"].as_table()->insert_or_assign("disabled_banner_types", bannerString); - - // Parse notify_banner_types using the same bannerTypes lookup table - auto notify_banner_types_str = get_config_or_default(config, parsed, "ui", "notify_banner_types", - DCU::notify_banner_types, write_log); - - std::vector notify_types = StrSplit(notify_banner_types_str, ','); - std::string notifyString; - - for (const auto& [key, value] : bannerTypes) { - auto upper_key = AsciiStrToUpper(key); - for (const std::string_view _type : notify_types) { - auto stripped_type = StripLeadingAsciiWhitespace(_type); - auto upper_type = AsciiStrToUpper(stripped_type); - spdlog::warn("TEMP: Notify {} == {}", upper_type, upper_key); - if ("ALL" == upper_type || upper_key == upper_type) { - this->notify_banner_types.emplace_back(value); - if (!notifyString.empty()) - notifyString.append(", "); - notifyString.append(key); - } - } - } - - spdlog::debug("Final notify banner types: {}", notifyString); - parsed["ui"].as_table()->insert_or_assign("notify_banner_types", notifyString); - - spdlog::debug(""); - parse_config_shortcut(config, parsed, "move_up", GameFunction::MoveUp, DCSH::move_up); parse_config_shortcut(config, parsed, "move_down", GameFunction::MoveDown, DCSH::move_down); parse_config_shortcut(config, parsed, "move_left", GameFunction::MoveLeft, DCSH::move_left); @@ -1529,7 +1528,8 @@ void Config::Load() spdlog::debug(""); - if (!std::filesystem::exists(File::MakePath(File::Config()))) { + const bool created_config = !std::filesystem::exists(File::MakePath(File::Config())); + if (created_config) { message.str(""); message << "Creating " << File::Config() << " (default config file)"; spdlog::warn(message.str()); @@ -1550,6 +1550,7 @@ void Config::Load() } Config::Save(parsed, File::Vars()); + runtime_config::Configure(created_config ? parsed : config); std::cout << "\n\n-----------------------------\n\n" << parsed << "\n\n-----------------------------\nVersion " diff --git a/mods/src/config.h b/mods/src/config.h index cb7e0952b..173d8795d 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -139,6 +139,12 @@ struct FleetLabelProfile { // Part of UI Scale void ApplyUiShipScaleToLoadedShips(float old_multiplier, float new_multiplier); +// Shared startup/editor parsers. False reports invalid tokens; known tokens are +// still returned so startup preserves its existing permissive interpretation. +bool ParseShipFilter(std::string_view value, std::vector& names, bool& match_all); +bool ParseFactionFilter(std::string_view value, std::vector& factions); +bool ParseBannerFilter(std::string_view value, std::vector& types); +std::string FormatBannerFilter(const std::vector& types); class Config final { @@ -284,6 +290,8 @@ class Config final bool installToastBannerHooks; bool installPanHooks; bool installHotkeyHooks; + bool installNativeSettings; + bool installRuntimeConfigHooks; bool installFreeResizeHooks; bool installTempCrashFixes; bool installTestPatches; diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc new file mode 100644 index 000000000..54a084229 --- /dev/null +++ b/mods/src/config_save.cc @@ -0,0 +1,132 @@ +#include "config_save.h" + +#include +#include +#include +#include +#include +#include +#include + +#if _WIN32 +#include +#endif + +// Compile-time substitutions are used only by the isolated failure fixture. +#ifndef CONFIG_SAVE_WRITE +#define CONFIG_SAVE_WRITE std::fwrite +#endif +#ifndef CONFIG_SAVE_CLOSE +#define CONFIG_SAVE_CLOSE std::fclose +#endif + +void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header) +{ + // Serialize and validate before opening any file. Values are encoded by toml++, + // never interpolated into TOML source. Validate the header too. + std::ostringstream output; + output.exceptions(std::ios::badbit | std::ios::failbit); + output << header << config; + const auto bytes = output.str(); + ReplaceConfigText(path, bytes); +} + +std::string ReadConfigText(const std::filesystem::path& path) +{ + std::ifstream input(path, std::ios::binary); + if (!input.is_open()) { + throw std::runtime_error("could not open config file"); + } + std::string text; + char buffer[8192]; + while (input.read(buffer, sizeof(buffer)) || input.gcount()) { + text.append(buffer, static_cast(input.gcount())); + } + if (!input.eof() || input.bad()) { + throw std::runtime_error("could not read config file"); + } + return text; +} + +bool ReplaceConfigText(const std::filesystem::path& path, std::string_view bytes, + std::optional expected) +{ + (void)toml::parse(bytes); + + // Follow existing symlinks as the former ofstream save did. A sibling stays on + // the same filesystem. Exclusive creation avoids truncating another save's file. + const auto destination = std::filesystem::weakly_canonical(path); + static std::atomic sequence{0}; + auto temporary = destination; + temporary += ".tmp-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + "-" + + std::to_string(sequence.fetch_add(1, std::memory_order_relaxed)); + + // C11 exclusive creation avoids depending on newer libc++ fstream runtime + // support on our minimum supported macOS version. +#if _WIN32 + std::FILE* file = nullptr; + _wfopen_s(&file, temporary.c_str(), L"wbx"); +#else + auto* file = std::fopen(temporary.c_str(), "wbx"); +#endif + if (!file) { + throw std::system_error(errno, std::generic_category(), "could not create temporary config file"); + } + + bool replacing = false; + try { + if (CONFIG_SAVE_WRITE(bytes.data(), 1, bytes.size(), file) != bytes.size()) { + throw std::system_error(errno, std::generic_category(), "could not write temporary config file"); + } + const auto closed = CONFIG_SAVE_CLOSE(file); // Includes flushing; failure prevents replacement. + file = nullptr; + if (closed != 0) { + throw std::system_error(errno, std::generic_category(), "could not close temporary config file"); + } + // Recheck after staging, immediately before commit. Another editor can still + // race the native replacement; arbitrary external editors do not share our lock. + if (expected && ReadConfigText(path) != *expected) { + std::error_code ignored; + std::filesystem::remove(temporary, ignored); + return false; + } +#if _WIN32 + // Let Windows retain the existing file's permissions and streams. A backup + // protects the old contents in ReplaceFile's documented partial-failure cases. + auto backup = temporary; + backup += ".bak"; + if (!ReplaceFileW(destination.c_str(), temporary.c_str(), backup.c_str(), 0, nullptr, nullptr)) { + auto error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND) { + // Missing-destination fallback: do not overwrite a file appearing before + // this move. The caller's earlier existence check is not a create-only transaction. + error = MoveFileExW(temporary.c_str(), destination.c_str(), 0) ? ERROR_SUCCESS : GetLastError(); + } + if (error != ERROR_SUCCESS) { + replacing = error == ERROR_UNABLE_TO_MOVE_REPLACEMENT || error == ERROR_UNABLE_TO_MOVE_REPLACEMENT_2; + throw std::filesystem::filesystem_error( + replacing ? "config replacement failed; retain temporary/backup for recovery" : "config replacement failed", + temporary, destination, std::error_code(error, std::system_category())); + } + } + std::error_code ignored; + std::filesystem::remove(backup, ignored); +#else + // Preserve ordinary permission bits when replacing an existing config. + if (std::filesystem::exists(destination)) { + std::filesystem::permissions(temporary, std::filesystem::status(destination).permissions()); + } + std::filesystem::rename(temporary, destination); +#endif + } catch (...) { + if (file) { + std::fclose(file); + } + std::error_code ignored; + if (!replacing) { + std::filesystem::remove(temporary, ignored); + } + throw; + } + return true; +} diff --git a/mods/src/config_save.h b/mods/src/config_save.h new file mode 100644 index 000000000..8e87aa8a3 --- /dev/null +++ b/mods/src/config_save.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include +#include +#include + +// Synchronous whole-document output for startup, not a runtime setting editor. +// Throws on failure; the caller owns reporting. Does not guarantee power-loss durability. +void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header = {}); + +std::string ReadConfigText(const std::filesystem::path& path); +// Preserves the supplied text. With expected text, false means an external edit +// was detected before replacement. This is not a filesystem compare-and-swap. +bool ReplaceConfigText(const std::filesystem::path& path, std::string_view text, + std::optional expected = std::nullopt); diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 4a0f5d448..428d1b947 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -84,6 +84,8 @@ namespace Patches constexpr bool freeresizehooks = true; constexpr bool game_version = true; constexpr bool hotkeyhooks = true; + constexpr bool nativesettingshooks = true; + constexpr bool runtimeconfighooks = true; constexpr bool loadingscreenhooks = true; constexpr bool transitionscreenhooks = true; constexpr bool objecttracker = true; diff --git a/mods/src/patches/key.cc b/mods/src/patches/key.cc index 2708cb858..4ca578d4d 100644 --- a/mods/src/patches/key.cc +++ b/mods/src/patches/key.cc @@ -77,6 +77,8 @@ const std::unordered_map Key::mappedKeys = { {"MOUSE6", KeyCode::Mouse6}, {"SPACE", KeyCode::Space}, {"MINUS", KeyCode::Minus}, + {"EQUAL", KeyCode::Equals}, + {"PIPE", KeyCode::Pipe}, {"_", KeyCode::Underscore}, {",", KeyCode::Comma}, {";", KeyCode::Semicolon}, @@ -277,7 +279,7 @@ void Key::ResetCache() // Prune them here as well as at the consumer so claims cannot survive a scene without NavigationPan. for (const auto key : directionalKeys) { auto& directionClaimed = claimedDirectionalInput[(int)key]; - if (directionClaimed && !Pressed(key)) { + if (directionClaimed && !RawPressed(key)) { directionClaimed = false; } } @@ -291,6 +293,9 @@ void Key::EnsureCurrentFrame() } bool Key::Down(KeyCode key) +{ return !SettingsOwnInput() && RawDown(key); } + +bool Key::RawDown(KeyCode key) { EnsureCurrentFrame(); @@ -305,6 +310,9 @@ bool Key::Down(KeyCode key) } bool Key::Pressed(KeyCode key) +{ return !SettingsOwnInput() && RawPressed(key); } + +bool Key::RawPressed(KeyCode key) { EnsureCurrentFrame(); @@ -318,6 +326,23 @@ bool Key::Pressed(KeyCode key) return cacheKeyPressed[(int)key] == 1; } +std::string Key::Token(KeyCode key) +{ + // Pick a stable accepted token, with separator-safe spellings. Sorting the + // aliases makes the result independent of unordered_map iteration order. + if (key == KeyCode::Minus) + return "MINUS"; + if (key == KeyCode::Equals) + return "EQUAL"; + if (key == KeyCode::Pipe) + return "PIPE"; + std::string result; + for (const auto& [token, value] : mappedKeys) + if (value == key && (result.empty() || token < result)) + result = token; + return result; +} + bool Key::IsInputFocused() { EnsureCurrentFrame(); diff --git a/mods/src/patches/key.h b/mods/src/patches/key.h index 81211c2e0..40151448f 100644 --- a/mods/src/patches/key.h +++ b/mods/src/patches/key.h @@ -28,6 +28,14 @@ class Key static bool Pressed(KeyCode key); static bool Down(KeyCode key); + // Editors alone sample raw input; every ordinary consumer respects ownership. + static bool RawPressed(KeyCode key); + static bool RawDown(KeyCode key); + static std::string Token(KeyCode key); + inline static bool shortcutCaptureActive = false, shortcutPopupActive = false; + inline static bool settingsSearchActive = false, textEntryActive = false; + static bool SettingsOwnInput() + { return shortcutCaptureActive || shortcutPopupActive || settingsSearchActive || textEntryActive; } static void ClaimDirectionalInput(KeyCode key); static bool IsDirectionalInputClaimed(); diff --git a/mods/src/patches/mapkey.cc b/mods/src/patches/mapkey.cc index a3234eeba..98bbee315 100644 --- a/mods/src/patches/mapkey.cc +++ b/mods/src/patches/mapkey.cc @@ -5,6 +5,7 @@ #include "str_utils.h" #include +#include #include #include #include @@ -61,6 +62,7 @@ constexpr auto kCompactShortcutTokenMappings = std::to_array(gameFunction)) = {std::string(key), std::string(defaultBinding)}; } + +const MapKey::ActionDefinition& MapKey::Definition(GameFunction gameFunction) +{ return definitions.at(static_cast(gameFunction)); } + +const std::vector& MapKey::Bindings(GameFunction gameFunction) +{ return mappedKeys.at(static_cast(gameFunction)); } + +bool MapKey::ReplaceBindings(GameFunction gameFunction, std::vector bindings) +{ + if (gameFunction < 0 || gameFunction >= GameFunction::Max) + return false; + for (const auto& binding : bindings) + if (binding.Key == KeyCode::None) + return false; + if (!bindings.empty()) + bindings.front().shortcutHint = CompactShortcutForHint(bindings.front().Shortcuts); + mappedKeys[gameFunction].swap(bindings); + return true; +} + // Movement reads the direction itself; claiming it would suppress its own pan // on the next frame. Other modified actions retain ownership until key release. static bool IsMovementAction(GameFunction action) @@ -275,6 +299,26 @@ bool MapKey::HasCorrectModifiers(const MapKey& mapKey) return result; } +bool MapKey::SameBinding(const MapKey& first, const MapKey& second) +{ + const auto containsAll = [](const auto& first, const auto& second) { + return std::all_of(first.begin(), first.end(), [&](const auto& modifier) { + return std::find(second.begin(), second.end(), modifier) != second.end(); + }); + }; + return first.Key != KeyCode::None && first.Key == second.Key && containsAll(first.Modifiers, second.Modifiers) + && containsAll(second.Modifiers, first.Modifiers); +} + +bool MapKey::MayOverlap(const MapKey& first, const MapKey& second) +{ + if (first.Key == KeyCode::None || first.Key != second.Key) + return false; + // Explicit modifiers are minimum requirements. Their union can satisfy both + // bindings, whereas an unmodified binding rejects every modifier. + return first.hasModifiers == second.hasModifiers; +} + std::string MapKey::GetParsedValues() const { std::string output = ""; @@ -289,3 +333,5 @@ std::string MapKey::GetParsedValues() const } std::array, (int)GameFunction::Max> MapKey::mappedKeys = {}; + +std::array MapKey::definitions = {}; diff --git a/mods/src/patches/mapkey.h b/mods/src/patches/mapkey.h index b5d73bedf..465a5f2a7 100644 --- a/mods/src/patches/mapkey.h +++ b/mods/src/patches/mapkey.h @@ -12,10 +12,20 @@ class MapKey { public: + struct ActionDefinition { + std::string key, defaultBinding; + }; MapKey(); static MapKey Parse(std::string_view key); static void AddMappedKey(GameFunction gameFunction, MapKey mappedKey); + static void RegisterAction(GameFunction action, std::string_view key, std::string_view defaultBinding); + static const ActionDefinition& Definition(GameFunction action); + static const std::vector& Bindings(GameFunction action); + static bool ReplaceBindings(GameFunction action, std::vector bindings); + static bool SameBinding(const MapKey& first, const MapKey& second); + // Advisory overlap under existing minimum-modifier dispatch rules. + static bool MayOverlap(const MapKey& first, const MapKey& second); static bool HasBinding(GameFunction gameFunction); static bool IsPressed(GameFunction gameFunction); static bool IsDown(GameFunction gameFunction); @@ -36,6 +46,8 @@ class MapKey private: static std::array, (int)GameFunction::Max> mappedKeys; + static std::array definitions; + bool hasModifiers; std::string shortcutHint; }; diff --git a/mods/src/patches/mission_hud.h b/mods/src/patches/mission_hud.h new file mode 100644 index 000000000..23da1f0db --- /dev/null +++ b/mods/src/patches/mission_hud.h @@ -0,0 +1,8 @@ +#pragma once +namespace mission_hud +{ +bool Available(); +bool CanChange(); +// Game-thread only. Reevaluate native visibility and apply current preferences. +void Refresh(); +} // namespace mission_hud diff --git a/mods/src/patches/modifierkey.cc b/mods/src/patches/modifierkey.cc index d9205193e..bb0793fe9 100644 --- a/mods/src/patches/modifierkey.cc +++ b/mods/src/patches/modifierkey.cc @@ -3,6 +3,7 @@ #include "key.h" #include "modifierkey.h" +#include #include #include #include @@ -12,6 +13,9 @@ bool ModifierKey::HasModifiers() const return this->hasModifier; } +bool ModifierKey::operator==(const ModifierKey& other) const +{ return std::is_permutation(Modifiers.begin(), Modifiers.end(), other.Modifiers.begin(), other.Modifiers.end()); } + ModifierKey::ModifierKey() { this->hasModifier = false; diff --git a/mods/src/patches/modifierkey.h b/mods/src/patches/modifierkey.h index a74587bca..2c4fd4455 100644 --- a/mods/src/patches/modifierkey.h +++ b/mods/src/patches/modifierkey.h @@ -21,6 +21,7 @@ struct ModifierKey bool IsPressed() const; bool IsDown() const; bool HasModifiers() const; + bool operator==(const ModifierKey& other) const; std::string GetParsedValues() const; diff --git a/mods/src/patches/parts/fc_confirmation_reset.cc b/mods/src/patches/parts/fc_confirmation_reset.cc new file mode 100644 index 000000000..3eae8a7e5 --- /dev/null +++ b/mods/src/patches/parts/fc_confirmation_reset.cc @@ -0,0 +1,165 @@ +#include "fc_confirmation_reset.h" +#include "settings/boolean_settings.h" +#include +#include + +namespace +{ +using namespace mod_settings; +constexpr const char* PreferenceKey = "options/hide_fcaa_use_confirmation"; + +bool ReferenceField(const FieldInfo* field) +{ + if (!field || field->type->byref) + return false; + const auto type = field->type->type; + return type == IL2CPP_TYPE_CLASS || type == IL2CPP_TYPE_GENERICINST || type == IL2CPP_TYPE_OBJECT; +} +Il2CppObject* ExistingSingleton(IL2CppClassHelper& helper) +{ + auto parent = helper.GetParent("MonoSingleton`1"); + auto* cls = parent.get_cls(); + auto* instanceField = cls ? il2cpp_class_get_field_from_name(cls, "s_instance") : nullptr; + auto* initializedField = cls ? il2cpp_class_get_field_from_name(cls, "s_initialized") : nullptr; + if (!ReferenceField(instanceField) || !(instanceField->type->attrs & FIELD_ATTRIBUTE_STATIC) || !initializedField + || initializedField->type->type != IL2CPP_TYPE_BOOLEAN + || !(initializedField->type->attrs & FIELD_ATTRIBUTE_STATIC)) + return nullptr; + bool initialized = false; + il2cpp_field_static_get_value(initializedField, &initialized); + if (!initialized) + return nullptr; + Il2CppObject* instance = nullptr; + il2cpp_field_static_get_value(instanceField, &instance); + return instance && il2cpp_class_is_assignable_from(helper.get_cls(), instance->klass) ? instance : nullptr; +} +bool BoolField(const FieldInfo* field) +{ + return field && field->type->type == IL2CPP_TYPE_BOOLEAN && !field->type->byref + && !(field->type->attrs & FIELD_ATTRIBUTE_STATIC); +} +bool InstanceMethod(const MethodInfo* method, int count, int result) +{ + return method && method->parameters_count == count && method->return_type->type == result + && !method->return_type->byref && !(method->flags & METHOD_ATTRIBUTE_STATIC); +} + +struct Binding { + IL2CppClassHelper prefs = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PersistentPrefs", "PersistentPrefsManager"); + IL2CppClassHelper fc = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.FleetCommander", "FleetCommanderManager"); + const MethodInfo* get = prefs.GetMethodInfo("GetBool", 3); + const MethodInfo* set = fc.GetMethodInfo("set_HideFleetCommanderAbilityUseConfirmationHidden", 1); + FieldInfo* loaded = + prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_isCloudFileLoaded") : nullptr; + FieldInfo* loading = prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_isLoading") : nullptr; + FieldInfo* saved = prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_savedData") : nullptr; + // Two bounded weak roots track storage replacement without retaining account data. + // Cleared on invalidation while IL2CPP is live, not in a process-exit destructor. + Il2CppGCHandle owner = nullptr, data = nullptr, key = nullptr; + std::uint64_t generation = 1; + bool supported() const + { + return BoolField(loaded) && BoolField(loading) && ReferenceField(saved) + && !(saved->type->attrs & FIELD_ATTRIBUTE_STATIC) && InstanceMethod(get, 3, IL2CPP_TYPE_BOOLEAN) + && get->parameters[0]->type == IL2CPP_TYPE_STRING && !get->parameters[0]->byref + && get->parameters[1]->type == IL2CPP_TYPE_BOOLEAN && !get->parameters[1]->byref + && get->parameters[2]->type == IL2CPP_TYPE_BOOLEAN && !get->parameters[2]->byref + && InstanceMethod(set, 1, IL2CPP_TYPE_VOID) && set->parameters[0]->type == IL2CPP_TYPE_BOOLEAN + && !set->parameters[0]->byref; + } + void invalidate() + { + if (owner) + il2cpp_gchandle_free(owner); + if (data) + il2cpp_gchandle_free(data); + owner = data = nullptr; + ++generation; + } + Il2CppObject* ready() + { + auto* p = ExistingSingleton(prefs); + bool isLoaded = false, isLoading = true; + Il2CppObject* state = nullptr; + if (p) { + il2cpp_field_get_value(p, loaded, &isLoaded); + il2cpp_field_get_value(p, loading, &isLoading); + il2cpp_field_get_value(p, saved, &state); + } + if (!p || !state || !isLoaded || isLoading) { + if (owner || data) + invalidate(); + return nullptr; + } + if (!owner || !data || il2cpp_gchandle_get_target(owner) != p || il2cpp_gchandle_get_target(data) != state) { + invalidate(); + owner = il2cpp_gchandle_new_weakref(p, false); + data = il2cpp_gchandle_new_weakref(state, false); + if (!owner || !data) { + invalidate(); + return nullptr; + } + } + return p; + } +}; +Binding& Backend() +{ + static Binding binding; + return binding; +} + +ReadResult ReadConfirmation() +{ + auto& b = Backend(); + if (!b.supported()) + return {Availability::Unsupported}; + auto* prefs = b.ready(); + if (!prefs) + return {}; + if (!b.key) + b.key = il2cpp_gchandle_new(reinterpret_cast(il2cpp_string_new(PreferenceKey)), false); + if (!b.key) + return {}; + // FC's native property uses GetBool(key,false,true). Display reads must not insert + // a missing preference: preserve its default but explicitly pass shouldAddKey=false. + bool defaultHidden = false, addKey = false; + void* args[] = {il2cpp_gchandle_get_target(b.key), &defaultHidden, &addKey}; + Il2CppException* exception = nullptr; + auto* value = il2cpp_runtime_invoke(b.get, prefs, args, &exception); + if (exception || !value) + return {}; + return ReadResult::Known(!*static_cast(il2cpp_object_unbox(value)), b.generation); +} +ApplyResult WriteConfirmation(bool enabled, std::uint64_t expectedGeneration) +{ + auto& b = Backend(); + if (!b.supported() || !b.ready() || b.generation != expectedGeneration) + return ApplyResult::Rejected; + auto* manager = ExistingSingleton(b.fc); + if (!manager) + return ApplyResult::Rejected; + bool hidden = !enabled; + void* args[] = {&hidden}; + Il2CppException* exception = nullptr; + il2cpp_runtime_invoke(b.set, manager, args, &exception); + return exception ? ApplyResult::Unverified : ApplyResult::Applied; +} +} // namespace + +mod_settings::BooleanSetting& FleetCommanderConfirmationSetting() +{ + static mod_settings::BooleanSetting setting({"community_mod.fc_ability_confirmation", + "[MOD] Confirm Fleet Commander abilities", ReadConfirmation, + WriteConfirmation}); + return setting; +} + +void InvalidateFleetCommanderConfirmationSession() +{ + // The native UI calls this before preference-session boundaries. + FleetCommanderConfirmationSetting().InvalidateSession(); + Backend().invalidate(); +} diff --git a/mods/src/patches/parts/fc_confirmation_reset.h b/mods/src/patches/parts/fc_confirmation_reset.h new file mode 100644 index 000000000..237e204a3 --- /dev/null +++ b/mods/src/patches/parts/fc_confirmation_reset.h @@ -0,0 +1,9 @@ +#pragma once + +namespace mod_settings +{ +class BooleanSetting; +} +// Game-thread preference adapter for the native confirmation settings UI. +mod_settings::BooleanSetting& FleetCommanderConfirmationSetting(); +void InvalidateFleetCommanderConfirmationSession(); diff --git a/mods/src/patches/parts/forbidden_tech_confirmation.cc b/mods/src/patches/parts/forbidden_tech_confirmation.cc index fdd617f26..02d32b612 100644 --- a/mods/src/patches/parts/forbidden_tech_confirmation.cc +++ b/mods/src/patches/parts/forbidden_tech_confirmation.cc @@ -12,6 +12,7 @@ namespace constexpr int32_t ConfirmButtonResult = 1; const FieldInfo* on_selection_field = nullptr; +bool controls_available = false; bool IsForbiddenTechConfirmation(const Il2CppDelegate* callback) { @@ -69,6 +70,9 @@ void MessageBox_ShowWithCallback_Hook(auto original, void* context, Il2CppDelega } } // namespace +bool ForbiddenTechControlsAvailable() +{ return controls_available; } + void InstallForbiddenTechConfirmationHooks() { auto message_box_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "MessageBox"); @@ -90,16 +94,18 @@ void InstallForbiddenTechConfirmationHooks() } const auto show = message_box_helper.GetMethod("Show", 1); + bool show_installed = false, callback_installed = false; if (show == nullptr) { ErrorMsg::MissingMethod("MessageBox", "Show"); } else { - SPUD_STATIC_DETOUR(show, MessageBox_Show_Hook); + show_installed = SPUD_STATIC_DETOUR(show, MessageBox_Show_Hook); } const auto show_with_callback = message_box_helper.GetMethod("Show", 2); if (show_with_callback == nullptr) { ErrorMsg::MissingMethod("MessageBox", "Show"); } else { - SPUD_STATIC_DETOUR(show_with_callback, MessageBox_ShowWithCallback_Hook); + callback_installed = SPUD_STATIC_DETOUR(show_with_callback, MessageBox_ShowWithCallback_Hook); } + controls_available = show_installed && callback_installed; } diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index c9392f5be..14ee1ecbb 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -1,4 +1,8 @@ #include "config.h" +#include "patches/native_hook_extent.h" +#include "patches/runtime_config.h" +#include "settings/upstream_settings.h" +#include "settings/warp_mode.h" #include #include @@ -330,27 +334,6 @@ void GotoSection(SectionID sectionID, void* screen_data = nullptr); bool CanHideViewers(); bool DidHideViewers(); -void CycleAutoConfirmInstantWarp(Config& config) -{ - const char* state = nullptr; - switch (config.auto_confirm_instant_warp) { - case InstantWarpConfirmation::None: - config.auto_confirm_instant_warp = InstantWarpConfirmation::Warp; - state = "warp"; - break; - case InstantWarpConfirmation::Warp: - config.auto_confirm_instant_warp = InstantWarpConfirmation::Jump; - state = "jump"; - break; - case InstantWarpConfirmation::Jump: - config.auto_confirm_instant_warp = InstantWarpConfirmation::None; - state = "none"; - break; - } - - spdlog::info("Auto-confirm instant warp set to {}", state); -} - bool MoveOfficerCanvas(bool goLeft) { auto selectors = ObjectFinder::GetAll(); @@ -709,16 +692,20 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) static int32_t last_ship_select_request = -1; Key::ResetCache(); + if (Key::SettingsOwnInput()) + return original(_this); if (MapKey::IsDown(GameFunction::DisableHotKeys)) { if (shortcut_hints_ready) { SetNativeShortcutHintsVisible(false); } Config::Get().hotkeys_enabled = false; + mod_settings::PersistCurrentSetting("control", "hotkeys_enabled"); spdlog::warn("Setting hotkeys to DISABLED"); return; } else if (MapKey::IsDown(GameFunction::EnableHotKeys)) { Config::Get().hotkeys_enabled = true; + mod_settings::PersistCurrentSetting("control", "hotkeys_enabled"); spdlog::warn("Setting hotkeys to ENABLED"); return; } @@ -794,7 +781,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) #ifdef _WIN32 if (MapKey::IsDown(GameFunction::Quit)) { - TerminateProcess(GetCurrentProcess(), 1); + runtime_config::ForceClose(); } #elif defined(__APPLE__) if (MapKey::IsDown(GameFunction::Quit)) { @@ -901,6 +888,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) if ((MapKey::IsDown(GameFunction::ToggleQueue))) { config->queue_enabled = !config->queue_enabled; + mod_settings::PersistCurrentSetting("control", "queue_enabled"); // Forget stale recovery authorizations; existing native target queues keep processing. ResetQueueRecoveryTracking(); return; @@ -1100,21 +1088,28 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) } else if (MapKey::IsPressed(GameFunction::UiViewerScaleDown)) { config->AdjustUiViewerScale(false); } else if (MapKey::IsDown(GameFunction::ToggleAutoConfirmInstantWarp)) { - CycleAutoConfirmInstantWarp(*config); + mod_settings::CycleWarpMode(); } else if (MapKey::IsDown(GameFunction::TogglePreviewLocate)) { config->disable_preview_locate = !config->disable_preview_locate; + mod_settings::PersistCurrentSetting("ui", "disable_preview_locate"); } else if (MapKey::IsDown(GameFunction::TogglePreviewRecall)) { config->disable_preview_recall = !config->disable_preview_recall; + mod_settings::PersistCurrentSetting("ui", "disable_preview_recall"); } else if (MapKey::IsDown(GameFunction::ToggleCargoDefault)) { config->show_cargo_default = !config->show_cargo_default; + mod_settings::PersistCurrentSetting("ui", "show_cargo_default"); } else if (MapKey::IsDown(GameFunction::ToggleCargoPlayer)) { config->show_player_cargo = !config->show_player_cargo; + mod_settings::PersistCurrentSetting("ui", "show_player_cargo"); } else if (MapKey::IsDown(GameFunction::ToggleCargoStation)) { config->show_station_cargo = !config->show_station_cargo; + mod_settings::PersistCurrentSetting("ui", "show_station_cargo"); } else if (MapKey::IsDown(GameFunction::ToggleCargoHostile)) { config->show_hostile_cargo = !config->show_hostile_cargo; + mod_settings::PersistCurrentSetting("ui", "show_hostile_cargo"); } else if (MapKey::IsDown(GameFunction::ToggleCargoArmada)) { config->show_armada_cargo = !config->show_armada_cargo; + mod_settings::PersistCurrentSetting("ui", "show_armada_cargo"); } else if (MapKey::IsDown(GameFunction::LogLevelOff)) { // spdlog::log("Setting log level to OFF"); spdlog::set_level(spdlog::level::off); @@ -1717,10 +1712,8 @@ bool install_screen_manager_update_hook() void InstallShortcutHintHooks() { - // Configuration is fully parsed before hook installation. NONE, empty, and invalid bindings opt out. - if (!MapKey::HasBinding(GameFunction::ToggleShortcutHints)) { - return; - } + // Install independently of the current binding: an initially unbound action + // can be assigned in Mod Settings later in this session. auto shortcuts_manager_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameInput", "ShortcutsManager"); @@ -1808,14 +1801,28 @@ void InstallShortcutHintHooks() if (get_show_keybindings && set_show_keybindings && can_use_shortcuts && initialize_actions_hook_ready && clear_text_override && override_localized_text && shortcut_hint_fields_ready && shortcut_hint_update_text && shortcut_hint_update_visibility && get_input_action && get_input_action_name) { +#if __APPLE__ + if (!native_hooks::MacHookFits(reinterpret_cast(shortcut_hint_update_text)) + || !native_hooks::MacHookFits(reinterpret_cast(shortcut_hint_update_visibility))) { + spdlog::warn("Shortcut hint controls unavailable: native hook extent could not be verified"); + return; + } +#endif MapKey::CacheShortcutHints(); original_shortcut_hint_update_text = SPUD_STATIC_DETOUR(shortcut_hint_update_text, ShortcutKeybindHint_UpdateText_Hook); - SPUD_STATIC_DETOUR(shortcut_hint_update_visibility, ShortcutKeybindHint_UpdateVisibility_Hook); - shortcut_hints_ready = true; + const bool visibility_installed = + SPUD_STATIC_DETOUR(shortcut_hint_update_visibility, ShortcutKeybindHint_UpdateVisibility_Hook); + shortcut_hints_ready = original_shortcut_hint_update_text && visibility_installed; } } +namespace mod_settings +{ +bool ShortcutHintControlAvailable() +{ return shortcut_hints_ready; } +} // namespace mod_settings + void InstallHotkeyHooks() { auto shortcuts_manager_helper = @@ -1852,8 +1859,7 @@ void InstallHotkeyHooks() if (ptr_can_user_shortcuts == nullptr) { ErrorMsg::MissingMethod("ShortcutsManager", "InitializeActions"); } else { - SPUD_STATIC_DETOUR(ptr_can_user_shortcuts, InitializeActions_Hook); - initialize_actions_hook_ready = true; + initialize_actions_hook_ready = SPUD_STATIC_DETOUR(ptr_can_user_shortcuts, InitializeActions_Hook); } } diff --git a/mods/src/patches/parts/mission_hud_tweaks.cc b/mods/src/patches/parts/mission_hud_tweaks.cc index 8eeee11fc..614db9f58 100644 --- a/mods/src/patches/parts/mission_hud_tweaks.cc +++ b/mods/src/patches/parts/mission_hud_tweaks.cc @@ -1,5 +1,6 @@ #include "config.h" #include "errormsg.h" +#include "patches/mission_hud.h" #include #include @@ -37,7 +38,84 @@ std::array g_button_definitions{{ std::vector g_configured_buttons; ComponentGetGameObjectFn g_get_game_object = nullptr; GameObjectSetActiveFn g_set_active = nullptr; -bool g_ready = false; + +using ObjectAliveFn = bool (*)(void*); +using ActiveSelfFn = bool (*)(void*); +using RefreshFn = void (*)(void*); +ObjectAliveFn g_alive = nullptr; +ActiveSelfFn g_active_self = nullptr; +ActiveSelfFn g_enabled = nullptr; +RefreshFn g_refresh_achievements = nullptr; +RefreshFn g_refresh_outposts = nullptr; +bool g_available = false; +bool g_refreshing = false; +struct HudInstance { + Il2CppGCHandle controller = nullptr; + Il2CppGCHandle missions = nullptr; + bool missions_default = true; + bool missions_overridden = false; +}; +std::vector g_instances; + +bool Alive(void* object) +{ return object && g_alive && g_alive(object); } +void* ButtonObject(void* controller, const MissionHudButtonDefinition& button) +{ + if (!Alive(controller) || !button.field_valid) + return nullptr; + auto* component = *reinterpret_cast(reinterpret_cast(controller) + button.field_offset); + return Alive(component) ? g_get_game_object(component) : nullptr; +} +bool Track(void* controller) +{ + if (!Alive(controller)) + return false; + if (g_refreshing) + return true; + for (auto it = g_instances.begin(); it != g_instances.end();) { + auto* target = il2cpp_gchandle_get_target(it->controller); + if (!Alive(target)) { + il2cpp_gchandle_free(it->controller); + if (it->missions) + il2cpp_gchandle_free(it->missions); + it = g_instances.erase(it); + } else { + if (target == controller) { + auto* missions = ButtonObject(controller, g_button_definitions[3]); + if (!Alive(missions)) + return false; + if (il2cpp_gchandle_get_target(it->missions) != missions) { + const auto replacement = il2cpp_gchandle_new_weakref(static_cast(missions), false); + if (!replacement) + return false; + il2cpp_gchandle_free(it->missions); + it->missions = replacement; + it->missions_overridden = false; + it->missions_default = g_active_self(missions); + } else if (!it->missions_overridden) { + it->missions_default = g_active_self(missions); + } + return true; + } + ++it; + } + } + auto* missions = ButtonObject(controller, g_button_definitions[3]); + if (!Alive(missions)) + return false; + const auto owner = il2cpp_gchandle_new_weakref(static_cast(controller), false); + const auto button = il2cpp_gchandle_new_weakref(static_cast(missions), false); + if (owner && button) { + g_instances.push_back({owner, button, g_active_self(missions)}); + return true; + } else { + if (owner) + il2cpp_gchandle_free(owner); + if (button) + il2cpp_gchandle_free(button); + } + return false; +} std::string_view to_string(MissionHudVisibility visibility) { @@ -57,6 +135,10 @@ std::vector LoadConfiguredButtons() std::vector configured_buttons; for (auto& definition : g_button_definitions) { definition.visibility = Config::Get().MissionHudButtonVisibility(definition.canonical_name); + if (definition.visibility == MissionHudVisibility::Auto) { + continue; + } + configured_buttons.emplace_back(&definition); } return configured_buttons; @@ -66,9 +148,6 @@ std::string ConfiguredButtonModes() { std::string modes; for (const auto* button : g_configured_buttons) { - if (button->visibility == MissionHudVisibility::Auto) { - continue; - } if (!modes.empty()) { modes.append(", "); } @@ -82,7 +161,8 @@ std::string ConfiguredButtonModes() bool ResolveButtonFields(IL2CppClassHelper& controller_helper, Il2CppClass* component_class) { auto valid_count = 0; - for (auto* button : g_configured_buttons) { + for (auto& definition : g_button_definitions) { + auto* button = &definition; auto field = controller_helper.GetField(button->field_name); auto* info = field.get_info(); const auto* type = info ? info->type : nullptr; @@ -102,22 +182,17 @@ bool ResolveButtonFields(IL2CppClassHelper& controller_helper, Il2CppClass* comp spdlog::info("MissionHudTweaks: mapped {} -> {}", button->canonical_name, button->field_name); } - return valid_count > 0; + return valid_count == g_button_definitions.size(); } void ApplyButtonVisibility(void* controller, const MissionHudButtonDefinition& button) { - if (!controller || !button.field_valid || !g_get_game_object || !g_set_active) { + if (!g_available || button.visibility == MissionHudVisibility::Auto) return; - } - - auto* component = *reinterpret_cast(reinterpret_cast(controller) + button.field_offset); - if (!component) { - return; - } - - if (auto* game_object = g_get_game_object(component)) { - g_set_active(game_object, button.visibility == MissionHudVisibility::Always); + if (auto* game_object = ButtonObject(controller, button); Alive(game_object)) { + const bool desired = button.visibility == MissionHudVisibility::Always; + if (g_active_self(game_object) != desired) + g_set_active(game_object, desired); } } } // namespace @@ -126,12 +201,29 @@ void ApplyButtonVisibility(void* controller, const MissionHudButtonDefinition& b // setup/notification work, then apply only explicitly configured overrides. void ApplyConfiguredButtonVisibility(void* controller) { - if (!g_ready) + if (!g_available) return; - for (auto* button : g_configured_buttons) { - button->visibility = Config::Get().MissionHudButtonVisibility(button->canonical_name); - if (button->visibility != MissionHudVisibility::Auto) - ApplyButtonVisibility(controller, *button); + if (!Track(controller)) + return; + for (auto& instance : g_instances) { + if (il2cpp_gchandle_get_target(instance.controller) != controller) + continue; + if (g_button_definitions[3].visibility == MissionHudVisibility::Auto) { + if (instance.missions_overridden) { + auto* missions = il2cpp_gchandle_get_target(instance.missions); + const bool baseline = instance.missions_default; + instance.missions_overridden = false; + if (Alive(missions) && ButtonObject(controller, g_button_definitions[3]) == missions + && g_active_self(missions) != baseline) + g_set_active(missions, baseline); + } + } else { + instance.missions_overridden = true; + } + break; + } + for (const auto* button : g_configured_buttons) { + ApplyButtonVisibility(controller, *button); } } @@ -207,6 +299,27 @@ void InstallMissionHudTweaksHooks() return; } + auto object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Object"); + const auto* alive = + method_contract::Resolve(object_helper.get_cls(), "op_Implicit", true, "System.Boolean", {"UnityEngine.Object"}); + const auto* active_self = + method_contract::Resolve(game_object_helper.get_cls(), "get_activeSelf", false, "System.Boolean", {}); + auto behaviour_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Behaviour"); + const auto* enabled_method = + method_contract::Resolve(behaviour_helper.get_cls(), "get_isActiveAndEnabled", false, "System.Boolean", {}); + g_alive = reinterpret_cast(method_contract::Pointer(alive)); + g_active_self = reinterpret_cast(method_contract::Pointer(active_self)); + g_enabled = reinterpret_cast(method_contract::Pointer(enabled_method)); + if (!g_alive || !g_active_self || !g_enabled) { + if (!g_alive) + ErrorMsg::MissingMethod("UnityEngine.Object", "op_Implicit"); + if (!g_active_self) + ErrorMsg::MissingMethod("UnityEngine.GameObject", "get_activeSelf"); + if (!g_enabled) + ErrorMsg::MissingMethod("UnityEngine.Behaviour", "get_isActiveAndEnabled"); + return; + } + // Resolve the complete surface before installing anything. Avoid the tiny // planetary/outpost event wrappers: these substantive methods own the work. const auto* on_enable = method_contract::Resolve( @@ -241,12 +354,14 @@ void InstallMissionHudTweaksHooks() return; } } - if (get_game_object->has_full_generic_sharing_signature || set_active->has_full_generic_sharing_signature) { - spdlog::error("MissionHudTweaks: {} uses unsupported generic sharing; overrides disabled", - get_game_object->has_full_generic_sharing_signature ? "Component.get_gameObject()" - : "GameObject.SetActive(Boolean)"); - return; - } + for (const auto* method : {get_game_object, set_active, alive, active_self, enabled_method}) + if (method->has_full_generic_sharing_signature) { + spdlog::error("MissionHudTweaks: {}.{} uses unsupported generic sharing; overrides disabled", method->klass->name, + method->name); + return; + } + g_refresh_achievements = reinterpret_cast(achievements->methodPointer); + g_refresh_outposts = reinterpret_cast(combined->methodPointer); const auto modes = ConfiguredButtonModes(); if (!modes.empty()) spdlog::info("MissionHudTweaks: applying {}", modes); @@ -259,9 +374,9 @@ void InstallMissionHudTweaksHooks() for (std::size_t i = 0; i < installed.size(); ++i) if (!installed[i]) spdlog::error("MissionHudTweaks: failed to install MissionsHudViewController.{}; overrides disabled", names[i]); - g_ready = enabled && achievement_hook && challenge_hook && outpost_hook && combined_hook; - if (g_ready) { - spdlog::info("MissionHudTweaks: installed current HUD lifecycle/setup hooks"); + if (enabled && achievement_hook && challenge_hook && outpost_hook && combined_hook) { + g_available = true; + spdlog::info("MissionHudTweaks: installed live HUD lifecycle/setup hooks"); } else { // Successfully installed detours still call through, but must not partially // enforce preferences when another refresh path could undo them. @@ -269,3 +384,52 @@ void InstallMissionHudTweaksHooks() spdlog::error("MissionHudTweaks: hook installation incomplete; overrides disabled"); } } + +namespace mission_hud +{ +bool Available() +{ return g_available; } +bool CanChange() +{ return g_available && !g_refreshing; } +void Refresh() +{ + if (!g_available || g_refreshing) + return; + g_configured_buttons = LoadConfiguredButtons(); + struct Guard { + Guard() + { g_refreshing = true; } + ~Guard() + { g_refreshing = false; } + } guard; + // Weak handles are not ownership. Recheck Unity's native lifetime after every + // managed call; refresh callbacks cannot modify the tracking collection. + for (const auto& instance : g_instances) { + auto* target = il2cpp_gchandle_get_target(instance.controller); + if (!Alive(target)) + continue; + const auto handle = il2cpp_gchandle_new(target, false); + if (!handle) + continue; + struct Root { + Il2CppGCHandle handle; + ~Root() + { il2cpp_gchandle_free(handle); } + } root{handle}; + auto current = [&]() -> void* { + auto* target = il2cpp_gchandle_get_target(root.handle); + return Alive(target) && g_enabled(target) ? target : nullptr; + }; + auto* controller = current(); + if (!controller) + continue; + g_refresh_achievements(controller); + if (!(controller = current())) + continue; + g_refresh_outposts(controller); + if (!(controller = current())) + continue; + ApplyConfiguredButtonVisibility(controller); + } +} +} // namespace mission_hud diff --git a/mods/src/patches/parts/mod_settings.cc b/mods/src/patches/parts/mod_settings.cc new file mode 100644 index 000000000..5ee3bb284 --- /dev/null +++ b/mods/src/patches/parts/mod_settings.cc @@ -0,0 +1,24 @@ +#include "settings/native/page_navigation.h" +#include "settings/native/value_widgets.h" +#include + +void InstallNativeSettings() +{ + // Resolve managed signatures and distinct targets before installing native UI hooks. +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + using namespace mod_settings::native; + try { + if (!InstallCoreValueWidgets()) + return; + try { + InstallPages(); + } catch (...) { + DisablePages(); + Warn("settings navigation installation unavailable; native confirmation control remains available"); + } + spdlog::info("[ModSettings] Native settings adapter installed"); + } catch (...) { + Warn("native settings installation unavailable"); + } +#endif +} diff --git a/mods/src/patches/parts/pinned_ship_click.cc b/mods/src/patches/parts/pinned_ship_click.cc index cdf52ba7d..66206a2fc 100644 --- a/mods/src/patches/parts/pinned_ship_click.cc +++ b/mods/src/patches/parts/pinned_ship_click.cc @@ -1,6 +1,6 @@ #include "config.h" -#include "file.h" #include "patches/key.h" +#include "settings/upstream_settings.h" #include "ship_name_match.h" #include "pinned_ship_click.h" @@ -14,10 +14,8 @@ #include #include -#include #include -#include #include #include @@ -42,42 +40,6 @@ bool IsShipManagementScreenVisible() return false; } -std::string JoinNames(const std::vector& names) -{ - std::string joined; - for (size_t i = 0; i < names.size(); ++i) { - if (i != 0) joined += ", "; - joined += names[i]; - } - return joined; -} - -// Update ui.pinned_ships in the user's config file so pins survive a restart. -// If/when we merge the runtime config writer we can route this through that instead of writing the file directly. -void PersistPinnedShips(const std::vector& names) -{ - const auto joined = JoinNames(names); - - toml::table table; - try { - if (std::filesystem::exists(File::Config())) { - table = toml::parse_file(File::Config()); - } - } catch (const std::exception& error) { - spdlog::warn("[PinnedShipClick] could not read '{}' to persist pinned ships: {}", File::Config(), error.what()); - return; - } - - if (!table["ui"].is_table()) { - table.emplace("ui", toml::table{}); - } - if (auto ui = table["ui"].as_table()) { - ui->insert_or_assign("pinned_ships", joined); - } - - Config::Save(table, File::Config(), false); -} - const MethodInfo* FindMethodInHierarchy(Il2CppClass* cls, const char* name, int argc) { for (; cls; cls = il2cpp_class_get_parent(cls)) { @@ -152,7 +114,7 @@ void PinShipClickOnTileClicked(FleetPlayerData* ship) spdlog::info("[PinnedShipClick] pinned ship '{}'", key); } - PersistPinnedShips(names); + mod_settings::PersistCurrentSetting("ui", "pinned_ships"); RefreshPinnedShipSortNow(); } diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc new file mode 100644 index 000000000..1593b129d --- /dev/null +++ b/mods/src/patches/parts/runtime_config.cc @@ -0,0 +1,371 @@ +#include "settings/setting_metadata.h" +#ifdef CONFIG_RUNTIME_TEST +#include CONFIG_RUNTIME_TEST // Isolated fixture substitutes Unity/worker boundaries only. +#else +#include "config.h" +#include "file.h" +#include "patches/runtime_config.h" +#include "runtime_config_writer.h" +#include + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "patches/screen_update_hook.h" +#if __APPLE__ +#include "patches/native_hook_extent.h" +#endif +#if _WIN32 +#include +#endif +#include +#include +#include +#endif +#endif + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + +namespace +{ +// Installed hooks live until process exit. Retain this one control object rather +// than joining a worker from DLL teardown. No worker is started during Configure. +config_edit::RuntimeConfigWriter* writer = nullptr; +bool available = false; +std::atomic owner{0}; +std::atomic_bool forcing{false}; +std::mutex lifecycle; +bool draining = false, stopped = false, resume = false; +std::uint64_t vote = 0; +thread_local unsigned quit_depth = 0; +void (*request_quit)(int) = nullptr; +void (*save_status_changed)() = nullptr; +std::atomic_bool persistence_unavailable{false}; +bool reported_save_failure = false; +std::uint64_t reported_status_sequence = 0; +std::atomic adapter_status_sequence{0}; +using SettingKey = std::pair; +std::map unavailable_settings; +#ifndef CONFIG_RUNTIME_TEST +std::optional startup_values; +#endif + +// Stable identity for the lifetime of the Unity owner thread on either platform. +std::uintptr_t CurrentThreadToken() +{ + static thread_local char token; + return reinterpret_cast(&token); +} + +void Report(std::string_view section, std::string_view key, config_edit::Outcome result) +{ + const char* reason = "write failed"; + switch (result) { + case config_edit::Outcome::Conflict: + reason = "file changed externally"; + break; + case config_edit::Outcome::InvalidDocument: + reason = "invalid TOML"; + break; + case config_edit::Outcome::Unsupported: + reason = "unsupported setting representation"; + break; + default: + break; + } + spdlog::warn("Could not persist {}.{}: {}; live setting is unchanged", section, key, reason); +} + +bool WantsQuit(auto original) +{ + struct Depth { + Depth() + { ++quit_depth; } + ~Depth() + { --quit_depth; } + } depth; + std::uint64_t this_vote; + { + std::lock_guard lock(lifecycle); + this_vote = ++vote; + } + const bool allows = original(); + std::lock_guard lock(lifecycle); + if (stopped || !writer) + return allows; + // A later/nested game veto must not be overwritten by an older returning vote. + if (this_vote == vote) + resume = allows; + if (allows) { + writer->Stop(false); + // Close admission before checking: Submit shares lifecycle, so no later + // request can race an idle exit. An already-deferred quit still observes + // native thread exit through Update before resuming. + if (!draining && !writer->HasWork() && resume) { + stopped = true; + resume = false; + return true; + } + draining = true; + } + return false; // Resume only after observing native worker exit, even after failure. +} + +void Update() +{ + std::uintptr_t unset = 0; + owner.compare_exchange_strong(unset, CurrentThreadToken()); + if (forcing || owner != CurrentThreadToken() || quit_depth) + return; + const bool failed = persistence_unavailable.load() || (writer && writer->HasFailures()); + const auto sequence = adapter_status_sequence.load() + (writer ? writer->StatusSequence() : 0); + if (failed != reported_save_failure || sequence != reported_status_sequence) { + reported_save_failure = failed; + reported_status_sequence = sequence; + if (save_status_changed) { + try { + save_status_changed(); + } catch (...) { /* Presentation cannot interrupt shutdown. */ + } + } + } + bool quit = false; + { + std::lock_guard lock(lifecycle); + if (!draining || stopped || !writer || !writer->PollStopped()) + return; + stopped = true; + quit = resume; + resume = false; // Consume before Unity callbacks; never retry a genuine veto. + } + if (quit) + request_quit(0); +} + +#if _WIN32 +DWORD WINAPI FinishForceClose(void* handle) +{ + WaitForSingleObject(handle, 500); + CloseHandle(handle); + TerminateProcess(GetCurrentProcess(), 1); + return 0; +} +#endif +} // namespace +#elif _WIN32 +#include +#endif + +namespace runtime_config +{ +bool SetSaveStatusObserver(void (*observer)()) +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + if (save_status_changed && save_status_changed != observer) + return false; + // Status must also update if persistence/quit-hook validation failed, or no + // writer was configured. Registration uses the existing idempotent dispatcher. + if (!observer || !install_screen_manager_update_hook() || !register_screen_manager_update_callback(Update)) + return false; + save_status_changed = observer; + return true; +#else + (void)observer; + return false; +#endif +} +bool HasSaveFailures() noexcept +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + return persistence_unavailable.load() || (writer && writer->HasFailures()); +#else + return false; +#endif +} +std::uint64_t StatusSequence() noexcept +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + return adapter_status_sequence.load() + (writer ? writer->StatusSequence() : 0); +#else + return 0; +#endif +} +bool RegisterSetting(const char* section, const char* key) noexcept +{ + try { +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + if (!section || !key || !*section || !*key) + return false; + std::lock_guard lock(lifecycle); + if (!writer || draining) + return false; + std::optional initial; +#ifndef CONFIG_RUNTIME_TEST + if (!startup_values) + return false; + const auto node = (*startup_values)[section][key]; + if (node.is_boolean()) + initial = *node.value(); + else if (node.is_string()) + initial = *node.value(); + else if (node.is_integer()) + initial = *node.value(); + else if (node.is_floating_point()) + initial = *node.value(); +#endif + return writer->Register(section, key, std::move(initial)); +#else + (void)section; + (void)key; +#endif + } catch (...) { + } + return false; +} +mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key) noexcept +{ + using namespace mod_settings; + try { +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + if (!section || !key) + return {SaveState::Unavailable}; + std::lock_guard lock(lifecycle); + if (unavailable_settings.contains(SettingKey{section, key})) + return {SaveState::Unavailable}; + if (!writer) + return {}; + const auto status = writer->GetStatus(section, key); + if (!status.revision) + return {}; + if (status.pending) + return {SaveState::Pending, status.revision}; + if (status.outcome == config_edit::Outcome::Saved || status.outcome == config_edit::Outcome::AlreadySaved) + return {SaveState::Saved, status.revision}; + if (status.outcome == config_edit::Outcome::Conflict) + return {SaveState::Conflict, status.revision}; + return {SaveState::Failed, status.revision}; +#else + (void)section; + (void)key; +#endif + } catch (...) { + return {SaveState::Unavailable}; + } + return {}; +} +#ifndef CONFIG_RUNTIME_TEST +void Configure(const toml::table& loaded) +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + if (writer) + return; + try { + startup_values = loaded; + writer = new config_edit::RuntimeConfigWriter(File::MakePath(File::Config()), Report); + } catch (...) { + spdlog::warn("Runtime config persistence unavailable"); + } +#else + (void)loaded; +#endif +} + +void Install() +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + static bool attempted = false; + if (attempted || !writer || !Config::Get().installRuntimeConfigHooks) + return; + attempted = true; + try { + auto helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Application"); + const auto* wants = + method_contract::Resolve(helper.get_cls(), "Internal_ApplicationWantsToQuit", true, "System.Boolean", {}); + const auto* quit = method_contract::Resolve(helper.get_cls(), "Quit", true, "System.Void", {"System.Int32"}); + if (!wants || !quit) { + spdlog::warn("Runtime config persistence unavailable: incompatible Unity quit methods"); + return; + } +#if __APPLE__ + if (!native_hooks::MacHookFits(reinterpret_cast(wants->methodPointer))) { + spdlog::warn("Runtime config persistence unavailable: Unity quit hook has insufficient native extent"); + return; + } +#endif + request_quit = reinterpret_cast(quit->methodPointer); + available = install_screen_manager_update_hook() && register_screen_manager_update_callback(Update) + && SPUD_STATIC_DETOUR(wants->methodPointer, WantsQuit); + spdlog::info("Runtime config persistence ready={}", available); + } catch (...) { + available = false; + } +#endif +} +#endif + +void SaveSetting(const char* section, const char* key, config_edit::Value value, + std::chrono::milliseconds delay) noexcept +{ + try { +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + if (section && key && available && !forcing && owner == CurrentThreadToken() && !quit_depth) { + std::lock_guard lock(lifecycle); + if (!draining) { + if (writer->Submit(section, key, std::move(value), delay)) { + if (unavailable_settings.erase(SettingKey{section, key})) { + persistence_unavailable.store(!unavailable_settings.empty()); + ++adapter_status_sequence; + } + return; + } + if (writer->HasFailure(section, key)) { + spdlog::warn("{}.{} changed for this session; runtime save submission failed", section, key); + return; // The writer owns this failure and its eventual same-key recovery. + } + } + } + { + std::lock_guard lock(lifecycle); + unavailable_settings.insert_or_assign(SettingKey{section ? section : "", key ? key : ""}, true); + ++adapter_status_sequence; + } + persistence_unavailable.store(true); +#else + (void)value; +#endif + static bool reported = false; + if (!reported) { + reported = true; + spdlog::warn("{}.{} changed for this session; runtime persistence unavailable", section, key); + } + } catch (...) { /* Persistence must not interrupt the shortcut's live effect. */ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + persistence_unavailable.store(true); +#endif + } +} + +#if _WIN32 +void ForceClose() noexcept +{ +#if defined(_M_X64) + if (writer && owner == CurrentThreadToken() && !quit_depth && writer->HasWork()) { + forcing = true; + writer->RequestCancelPending(); + HANDLE duplicate = nullptr; + if (auto handle = writer->NativeHandle(); + handle + && DuplicateHandle(GetCurrentProcess(), handle, GetCurrentProcess(), &duplicate, SYNCHRONIZE, FALSE, 0)) { + // Arm the independent deadline before taking any writer lock. A stalled + // filesystem operation or Unity callback cannot prolong this best effort. + if (auto closer = CreateThread(nullptr, 0, FinishForceClose, duplicate, 0, nullptr)) { + CloseHandle(closer); + writer->Stop(true); + return; + } + CloseHandle(duplicate); + } + } +#endif + TerminateProcess(GetCurrentProcess(), 1); +} +#endif +} // namespace runtime_config diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index 2fb7af700..3a8b2d9ed 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -1,5 +1,6 @@ #include "config.h" #include "errormsg.h" +#include "patches/native_hook_extent.h" #include @@ -16,12 +17,74 @@ #include #include +#include +#include #include #include +#include namespace { -std::unordered_map fleet_label_widgets; +struct FleetLabelWidget { + Il2CppGCHandle lod{}, widget{}; + bool overridden = false; +}; +std::unordered_map fleet_label_widgets; +using FleetLabelAliveFn = bool (*)(void *); +FleetLabelAliveFn fleet_label_alive = nullptr; +bool FleetLabelAlive(void *object) +{ return object && fleet_label_alive && fleet_label_alive(object); } +struct FleetLabelRoot { + Il2CppGCHandle handle{}; + explicit FleetLabelRoot(void *object) + : handle(object ? il2cpp_gchandle_new(static_cast(object), false) : nullptr) + { + } + ~FleetLabelRoot() + { + if (handle) + il2cpp_gchandle_free(handle); + } + FleetLabelRoot(const FleetLabelRoot &) = delete; +}; +void FreeFleetLabelWidget(FleetLabelWidget &entry) +{ + if (entry.lod) + il2cpp_gchandle_free(entry.lod); + if (entry.widget) + il2cpp_gchandle_free(entry.widget); + entry = {}; +} +bool ForgetFleetLabelWidget(NavigationLOD *lod, NavigationFleetWidget *widget) +{ + const auto found = fleet_label_widgets.find(lod); + if (found == fleet_label_widgets.end() + || il2cpp_gchandle_get_target(found->second.widget) != reinterpret_cast(widget)) + return false; + const bool overridden = found->second.overridden; + FreeFleetLabelWidget(found->second); + fleet_label_widgets.erase(found); + return overridden; +} +void TrackFleetLabelWidget(NavigationLOD *lod, NavigationFleetWidget *widget) +{ + if (!FleetLabelAlive(lod) || !FleetLabelAlive(widget) || !widget->Context) + return; + const auto found = fleet_label_widgets.find(lod); + if (found != fleet_label_widgets.end() + && il2cpp_gchandle_get_target(found->second.lod) == reinterpret_cast(lod) + && il2cpp_gchandle_get_target(found->second.widget) == reinterpret_cast(widget)) + return; + FleetLabelWidget entry{il2cpp_gchandle_new_weakref(reinterpret_cast(lod), false), + il2cpp_gchandle_new_weakref(reinterpret_cast(widget), false)}; + if (!entry.lod || !entry.widget) { + FreeFleetLabelWidget(entry); + return; + } + if (found != fleet_label_widgets.end()) + FreeFleetLabelWidget(found->second); + fleet_label_widgets.insert_or_assign(lod, entry); +} bool fleet_label_hooks_installed = false; uintptr_t active_system_zoom_id = 0; bool system_zoom_state_valid = false; @@ -47,15 +110,21 @@ bool FleetLabelThresholdEnabled() const FleetLabelProfile *FleetLabelProfileFor(NavigationFleetWidget *widget) { - if (widget == nullptr) { + if (!FleetLabelAlive(widget)) { return nullptr; } + FleetLabelRoot widget_root(widget); + if (!widget_root.handle) + return nullptr; const auto &config = Config::Get(); auto *context = widget->Context; if (context == nullptr) { return &config.zoom_label_non_player; } + FleetLabelRoot context_root(context); + if (!context_root.handle) + return nullptr; DeployedFleetType fleet_type; if (!context->TryGetFleetType(fleet_type)) { @@ -79,11 +148,13 @@ const FleetLabelProfile *FleetLabelProfileFor(NavigationFleetWidget *widget) const FleetLabelProfile *FleetLabelProfileFor(NavigationLOD *lod) { - const auto widget = fleet_label_widgets.find(lod); - if (widget == fleet_label_widgets.end() || widget->second == nullptr || widget->second->Context == nullptr) { + const auto entry = fleet_label_widgets.find(lod); + if (entry == fleet_label_widgets.end() + || il2cpp_gchandle_get_target(entry->second.lod) != reinterpret_cast(lod) + || !FleetLabelAlive(lod)) return nullptr; - } - return FleetLabelProfileFor(widget->second); + auto *widget = reinterpret_cast(il2cpp_gchandle_get_target(entry->second.widget)); + return FleetLabelAlive(widget) && widget->Context ? FleetLabelProfileFor(widget) : nullptr; } ZoomLevels ExpandedFleetLabelZoomLevel(ZoomLevels native_level) @@ -120,32 +191,57 @@ ZoomLevels FleetLabelZoomLevel(ZoomLevels native_level, const FleetLabelProfile } } -void ApplyFleetLabelDetail(NavigationLOD *lod, ZoomLevels native_level) +void RestoreNativeFleetLabelDetail(NavigationLOD *lod) { - if (lod == nullptr || !system_zoom_state_valid || FleetLabelProfileFor(lod) == nullptr) { + if (!FleetLabelAlive(lod) || !system_zoom_state_valid) return; - } - - lod->UpdateLOD(native_level); + FleetLabelRoot root(lod); + if (!root.handle) + return; + lod->SetTargetLevel(system_zoom_level); + lod->UpdateLOD(system_zoom_level); + if (FleetLabelAlive(lod)) + lod->SetTargetLevel(system_zoom_level); } -void ApplyAllFleetLabelDetails() +void ApplyFleetLabelDetail(NavigationLOD *lod, ZoomLevels native_level) { - for (const auto &[lod, widget] : fleet_label_widgets) { - (void)widget; - ApplyFleetLabelDetail(lod, system_zoom_level); + if (!system_zoom_state_valid) + return; + const auto *profile = FleetLabelProfileFor(lod); + if (!profile) + return; + if (profile->detail == FleetLabelDetail::Native) { + const auto found = fleet_label_widgets.find(lod); + if (found != fleet_label_widgets.end() && found->second.overridden) { + found->second.overridden = false; + RestoreNativeFleetLabelDetail(lod); + } + return; } + FleetLabelRoot root(lod); + if (root.handle) + lod->UpdateLOD(native_level); } -void RestoreNativeFleetLabelDetail(NavigationLOD *lod) +void ApplyAllFleetLabelDetails() { - if (lod == nullptr || !system_zoom_state_valid) { - return; + // Native LOD callbacks can release/rebind other widgets. Snapshot identities, + // then re-resolve weak roots before each call; never iterate across callbacks. + std::vector labels; + labels.reserve(fleet_label_widgets.size()); + for (auto entry = fleet_label_widgets.begin(); entry != fleet_label_widgets.end();) { + if (il2cpp_gchandle_get_target(entry->second.lod) != reinterpret_cast(entry->first) + || !FleetLabelAlive(entry->first) || !FleetLabelAlive(il2cpp_gchandle_get_target(entry->second.widget))) { + FreeFleetLabelWidget(entry->second); + entry = fleet_label_widgets.erase(entry); + } else { + labels.push_back(entry->first); + ++entry; + } } - - lod->SetTargetLevel(system_zoom_level); - lod->UpdateLOD(system_zoom_level); - lod->SetTargetLevel(system_zoom_level); + for (auto *lod : labels) + ApplyFleetLabelDetail(lod, system_zoom_level); } void ResetFleetLabelSystemState() @@ -197,6 +293,17 @@ void UpdateFleetLabelThreshold() } } // namespace +bool FleetLabelControlsAvailable() +{ return fleet_label_hooks_installed; } + +void RefreshFleetLabelControls() +{ + if (!fleet_label_hooks_installed) + return; + threshold_state_valid = false; + ApplyAllFleetLabelDetails(); +} + vec3 GetMouseWorldPos(void *cam, vec3 *pos) { static auto class_helper = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.Client.Core", "MathUtils"); @@ -451,21 +558,32 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) void NavigationLOD_UpdateLOD_Hook(auto original, NavigationLOD *_this, ZoomLevels level) { - const auto *profile = FleetLabelProfileFor(_this); - const auto effective_level = profile != nullptr ? FleetLabelZoomLevel(level, *profile) : level; - if (profile != nullptr) { - _this->SetTargetLevel(effective_level); + if (!fleet_label_hooks_installed || !FleetLabelProfilesEnabled()) { + original(_this, level); + return; } + const auto *profile = fleet_label_hooks_installed ? FleetLabelProfileFor(_this) : nullptr; + if (!profile || profile->detail == FleetLabelDetail::Native) { + original(_this, level); + return; + } + FleetLabelRoot root(_this); + if (!root.handle) { + original(_this, level); + return; + } + const auto effective_level = FleetLabelZoomLevel(level, *profile); + fleet_label_widgets.at(_this).overridden = true; + _this->SetTargetLevel(effective_level); original(_this, effective_level); - if (profile != nullptr) { + if (FleetLabelAlive(_this) && FleetLabelProfileFor(_this)) _this->SetTargetLevel(effective_level); - } } void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_this) { original(_this); - if (!_this) { + if (!fleet_label_hooks_installed || !FleetLabelAlive(_this)) { return; } @@ -477,7 +595,7 @@ void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_ auto *lod = _this->_lod; if (lod) { - fleet_label_widgets.insert_or_assign(lod, _this); + TrackFleetLabelWidget(lod, _this); if (FleetLabelThresholdEnabled() && !system_zoom_state_valid) { threshold_state_valid = false; } @@ -487,12 +605,10 @@ void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget *_ void NavigationFleetWidget_OnDisable_Hook(auto original, NavigationFleetWidget *_this) { - if (_this) { + if (fleet_label_hooks_installed && FleetLabelAlive(_this)) { auto *lod = _this->_lod; - fleet_label_widgets.erase(lod); - if (_this->Context != nullptr) { + if (ForgetFleetLabelWidget(lod, _this) && _this->Context) RestoreNativeFleetLabelDetail(lod); - } } original(_this); } @@ -500,27 +616,23 @@ void NavigationFleetWidget_OnDisable_Hook(auto original, NavigationFleetWidget * void NavigationFleetWidget_OnDidBindContext_Hook(auto original, NavigationFleetWidget *_this) { original(_this); - if (_this == nullptr) { + if (!fleet_label_hooks_installed || !FleetLabelAlive(_this)) { return; } auto *lod = _this->_lod; if (lod != nullptr) { - fleet_label_widgets.insert_or_assign(lod, _this); + TrackFleetLabelWidget(lod, _this); ApplyFleetLabelDetail(lod, system_zoom_level); } } void NavigationFleetWidget_OnAboutToReleaseContext_Hook(auto original, NavigationFleetWidget *_this) { - if (_this != nullptr) { + if (fleet_label_hooks_installed && FleetLabelAlive(_this)) { auto *lod = _this->_lod; - if (lod != nullptr) { - fleet_label_widgets.erase(lod); - if (_this->Context != nullptr) { - RestoreNativeFleetLabelDetail(lod); - } - } + if (ForgetFleetLabelWidget(lod, _this) && _this->Context) + RestoreNativeFleetLabelDetail(lod); } original(_this); } @@ -639,7 +751,10 @@ void *PlanetViewUtils_get_FlatRenderable_Hook(auto original, PlanetViewUtils *_t void InstallZoomHooks() { auto navigation_zoom_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationZoom"); - auto ptr_update = navigation_zoom_helper.isValidHelper() ? navigation_zoom_helper.GetMethod("Update") : nullptr; + const auto *update_method = + method_contract::Resolve(navigation_zoom_helper.get_cls(), "Update", false, "System.Void", {}); + auto *ptr_update = method_contract::Pointer(update_method); + bool label_surface_ready = false; auto *navigation_zoom_class = navigation_zoom_helper.get_cls(); auto *zoom_level_field = navigation_zoom_class != nullptr ? il2cpp_class_get_field_from_name(navigation_zoom_class, "_zoomLevel") @@ -647,19 +762,29 @@ void InstallZoomHooks() auto *normalized_zoom_property = navigation_zoom_class != nullptr ? il2cpp_class_get_property_from_name(navigation_zoom_class, "NormalizedZoom") : nullptr; - if (FleetLabelProfilesEnabled()) { + // Install independently of the selected profile so settings can switch from + // Native to an override later in the same session. + { auto lod_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationLOD"); auto fleet_widget_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationFleetWidget"); auto fleet_data_helper = il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData"); - auto ptr_update_lod = lod_helper.isValidHelper() ? lod_helper.GetMethod("UpdateLOD") : nullptr; - auto ptr_on_enable = fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnEnable") : nullptr; - auto ptr_on_disable = fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnDisable") : nullptr; - auto ptr_on_did_bind_context = - fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnDidBindContext") : nullptr; - auto ptr_on_about_to_release_context = - fleet_widget_helper.isValidHelper() ? fleet_widget_helper.GetMethod("OnAboutToReleaseContext") : nullptr; + const auto *update_lod = method_contract::Resolve(lod_helper.get_cls(), "UpdateLOD", false, "System.Void", + {"Digit.Prime.Navigation.ZoomLevels"}); + const auto *on_enable = + method_contract::Resolve(fleet_widget_helper.get_cls(), "OnEnable", false, "System.Void", {}); + const auto *on_disable = + method_contract::Resolve(fleet_widget_helper.get_cls(), "OnDisable", false, "System.Void", {}); + const auto *did_bind = + method_contract::Resolve(fleet_widget_helper.get_cls(), "OnDidBindContext", false, "System.Void", {}); + const auto *release = + method_contract::Resolve(fleet_widget_helper.get_cls(), "OnAboutToReleaseContext", false, "System.Void", {}); + auto *ptr_update_lod = method_contract::Pointer(update_lod); + auto *ptr_on_enable = method_contract::Pointer(on_enable); + auto *ptr_on_disable = method_contract::Pointer(on_disable); + auto *ptr_on_did_bind_context = method_contract::Pointer(did_bind); + auto *ptr_on_about_to_release_context = method_contract::Pointer(release); auto *lod_class = lod_helper.get_cls(); auto *target_level_field = lod_class != nullptr ? il2cpp_class_get_field_from_name(lod_class, "k__BackingField") : nullptr; @@ -724,20 +849,82 @@ void InstallZoomHooks() } } + auto object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Object"); + const auto *alive = method_contract::Resolve(object_helper.get_cls(), "op_Implicit", true, "System.Boolean", + {"UnityEngine.Object"}); + const auto *normalized = + method_contract::Resolve(navigation_zoom_class, "get_NormalizedZoom", false, "System.Single", {}); + const auto *fleet_type = method_contract::Resolve(fleet_data_class, "get_FleetType", false, + "Digit.PrimeServer.Models.DeployedFleetType", {}); + const auto fieldFits = [](const FieldInfo *field, Il2CppClass *owner, std::size_t bytes) { + return field && field->type && !field->type->byref && owner + && !(il2cpp_field_get_flags(const_cast(field)) & FIELD_ATTRIBUTE_STATIC) + && field->offset >= sizeof(Il2CppObject) && field->offset + bytes <= il2cpp_class_instance_size(owner); + }; + const auto enumField = [&](const FieldInfo *field, Il2CppClass *owner) { + if (!fieldFits(field, owner, sizeof(ZoomLevels)) + || !method_contract::Type(field->type, "Digit.Prime.Navigation.ZoomLevels")) + return false; + auto *cls = il2cpp_class_from_type(field->type); + const auto *storage = cls && il2cpp_class_is_enum(cls) ? il2cpp_class_enum_basetype(cls) : nullptr; + return storage && storage->type == IL2CPP_TYPE_I4; + }; + const auto referenceField = [&](const FieldInfo *field, Il2CppClass *owner) { + return fieldFits(field, owner, sizeof(void *)) + && (field->type->type == IL2CPP_TYPE_CLASS || field->type->type == IL2CPP_TYPE_GENERICINST + || field->type->type == IL2CPP_TYPE_OBJECT || field->type->type == IL2CPP_TYPE_VAR); + }; + const std::array methods{update_lod, on_enable, on_disable, did_bind, release, update_method}; + bool signatures_valid = alive && normalized && fleet_type; + for (std::size_t i = 0; i < methods.size(); ++i) { + if (!methods[i] || methods[i]->has_full_generic_sharing_signature) { + signatures_valid = false; + continue; + } + for (std::size_t j = 0; j < i; ++j) + if (methods[j] && methods[i]->methodPointer == methods[j]->methodPointer) + signatures_valid = false; + } + for (const auto *method : {alive, normalized, fleet_type}) + if (method && method->has_full_generic_sharing_signature) + signatures_valid = false; +#if defined(__APPLE__) + // Profiles can now be enabled after startup, so these five detours are + // installed even when the initial profile is Native. Check every entry + // before any of them is patched; unsupported Mac images retain native LOD. + for (const auto *method : {update_lod, on_enable, on_disable, did_bind, release}) + if (method && !native_hooks::MacHookFits(reinterpret_cast(method->methodPointer))) { + signatures_valid = false; + spdlog::warn("Fleet labels: {}.{} has no verified native hook extent; controls unavailable", + method->klass->name, method->name); + } +#endif + const auto fields_valid = enumField(target_level_field, lod_class) + && enumField(zoom_level_field, navigation_zoom_class) + && referenceField(lod_field, fleet_widget_class) + && method_contract::Type(lod_field->type, "Digit.Prime.Navigation.NavigationLOD") + && referenceField(context_field, fleet_widget_class); const auto fleet_label_dependencies_valid = - lod_helper.isValidHelper() && ptr_update_lod != nullptr && target_level_field != nullptr - && fleet_widget_helper.isValidHelper() && ptr_on_enable != nullptr && ptr_on_disable != nullptr - && ptr_on_did_bind_context != nullptr && ptr_on_about_to_release_context != nullptr && lod_field != nullptr - && context_field != nullptr && fleet_data_helper.isValidHelper() && fleet_type_property != nullptr - && fleet_type_getter != nullptr && navigation_zoom_helper.isValidHelper() && ptr_update != nullptr - && zoom_level_field != nullptr && normalized_zoom_property != nullptr; + signatures_valid && fields_valid && lod_helper.isValidHelper() && ptr_update_lod != nullptr + && target_level_field != nullptr && fleet_widget_helper.isValidHelper() && ptr_on_enable != nullptr + && ptr_on_disable != nullptr && ptr_on_did_bind_context != nullptr && ptr_on_about_to_release_context != nullptr + && lod_field != nullptr && context_field != nullptr && fleet_data_helper.isValidHelper() + && fleet_type_property != nullptr && fleet_type_getter != nullptr && navigation_zoom_helper.isValidHelper() + && ptr_update != nullptr && zoom_level_field != nullptr && normalized_zoom_property != nullptr; if (fleet_label_dependencies_valid) { - fleet_label_hooks_installed = true; - SPUD_STATIC_DETOUR(ptr_update_lod, NavigationLOD_UpdateLOD_Hook); - SPUD_STATIC_DETOUR(ptr_on_enable, NavigationFleetWidget_OnEnable_Hook); - SPUD_STATIC_DETOUR(ptr_on_disable, NavigationFleetWidget_OnDisable_Hook); - SPUD_STATIC_DETOUR(ptr_on_did_bind_context, NavigationFleetWidget_OnDidBindContext_Hook); - SPUD_STATIC_DETOUR(ptr_on_about_to_release_context, NavigationFleetWidget_OnAboutToReleaseContext_Hook); + fleet_label_alive = reinterpret_cast(method_contract::Pointer(alive)); + // A partial installation stays on the original native paths. The final + // capability also requires the shared NavigationZoom.Update owner below. + const std::array installed{ + bool(SPUD_STATIC_DETOUR(ptr_update_lod, NavigationLOD_UpdateLOD_Hook)), + bool(SPUD_STATIC_DETOUR(ptr_on_enable, NavigationFleetWidget_OnEnable_Hook)), + bool(SPUD_STATIC_DETOUR(ptr_on_disable, NavigationFleetWidget_OnDisable_Hook)), + bool(SPUD_STATIC_DETOUR(ptr_on_did_bind_context, NavigationFleetWidget_OnDidBindContext_Hook)), + bool( + SPUD_STATIC_DETOUR(ptr_on_about_to_release_context, NavigationFleetWidget_OnAboutToReleaseContext_Hook))}; + label_surface_ready = std::ranges::all_of(installed, [](bool installed) { return installed; }); + if (!label_surface_ready) + spdlog::error("Fleet label detail hook installation incomplete; using native fleet labels"); } else { spdlog::error("Fleet label detail hooks were not installed; using native fleet labels"); } @@ -791,7 +978,8 @@ void InstallZoomHooks() if (ptr_update == nullptr) { ErrorMsg::MissingMethod("NavigationZoom", "Update"); } else { - SPUD_STATIC_DETOUR(ptr_update, NavigationZoom_Update_Hook); + const bool zoom_installed = SPUD_STATIC_DETOUR(ptr_update, NavigationZoom_Update_Hook); + fleet_label_hooks_installed = label_surface_ready && zoom_installed; } #if _WIN32 diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 57a45e104..0fdb6c97f 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -1,6 +1,7 @@ #include "patches.h" #include "file.h" #include "log_history.h" +#include "patches/runtime_config.h" #include "version.h" #include @@ -59,6 +60,7 @@ void InstallActionQueueRecovery(); void InstallThinQueueProtection(); void InstallQueueAddressGuard(); void InstallHavenHistoryHooks(); +void InstallNativeSettings(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -144,6 +146,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) spdlog::info("Initializing code hooks:"); const PatchEntry patches[] = { {"UiScaleHooks", {InstallUiScaleHooks, &cfg.installUiScaleHooks}}, + {"RuntimeConfig", {runtime_config::Install, &cfg.installRuntimeConfigHooks}}, {"ZoomHooks", {InstallZoomHooks, &cfg.installZoomHooks}}, {"HavenZoomHooks", {InstallHavenZoomHooks, &cfg.installHavenZoomHooks}}, {"BuffFixHooks", {InstallBuffFixHooks, &cfg.installBuffFixHooks}}, @@ -182,6 +185,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"QueueAddressGuard", {InstallQueueAddressGuard, &cfg.installQueueAddressGuardHooks}}, {"ThinQueueProtection", {InstallThinQueueProtection, &cfg.installThinQueueProtectionHooks}}, {"HavenHistory", {InstallHavenHistoryHooks, &cfg.installHavenHistoryHooks}}, + {"ModSettings", {InstallNativeSettings, &cfg.installNativeSettings}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); diff --git a/mods/src/patches/runtime_config.h b/mods/src/patches/runtime_config.h new file mode 100644 index 000000000..a2f8b66a4 --- /dev/null +++ b/mods/src/patches/runtime_config.h @@ -0,0 +1,26 @@ +#pragma once +#include "settings/setting_metadata.h" +#include "toml_editor.h" +#include +#include + +namespace runtime_config +{ +// Startup only: retain the semantic disk value as the optimistic comparison base. +void Configure(const toml::table& loaded); +void Install(); +// Register one process-lifetime observer during patch installation. Called on +// the game thread when per-key status changes, never by the worker. +bool SetSaveStatusObserver(void (*observer)()); +bool HasSaveFailures() noexcept; +// Definitions register their key once. Repeated registrations preserve the +// original startup comparison value and do not read or write the disk. +bool RegisterSetting(const char* section, const char* key) noexcept; +mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key) noexcept; +std::uint64_t StatusSequence() noexcept; +void SaveSetting(const char* section, const char* key, config_edit::Value value, + std::chrono::milliseconds delay = {}) noexcept; +#if _WIN32 +void ForceClose() noexcept; +#endif +} // namespace runtime_config diff --git a/mods/src/runtime_config_writer.cc b/mods/src/runtime_config_writer.cc new file mode 100644 index 000000000..3ea12f883 --- /dev/null +++ b/mods/src/runtime_config_writer.cc @@ -0,0 +1,224 @@ +#include "runtime_config_writer.h" +#include + +#if _WIN32 +#include +#endif + +#ifndef CONFIG_EDIT_SAVE +#define CONFIG_EDIT_SAVE(editor, path, request) (editor).Save(path, request) +#endif +#ifndef CONFIG_EDIT_START_WORKER +#define CONFIG_EDIT_START_WORKER(...) std::thread(__VA_ARGS__) +#endif + +namespace config_edit +{ +RuntimeConfigWriter::RuntimeConfigWriter(std::filesystem::path path, Reporter report) + : path_(std::move(path)) + , report_(report) +{ +} + +RuntimeConfigWriter::~RuntimeConfigWriter() +{ + Stop(false); + if (worker_.joinable()) + worker_.join(); +} + +bool RuntimeConfigWriter::Register(std::string section, std::string key, std::optional initial) +{ + std::lock_guard lock(mutex_); + if (stopping_ || cancel_pending_.load() || section.empty() || key.empty()) + return false; + saved_.try_emplace(Key{std::move(section), std::move(key)}, + Saved{std::move(initial), false, {0, Outcome::AlreadySaved, false, true}}); + return true; +} + +std::uint64_t RuntimeConfigWriter::Submit(std::string section, std::string key, Value desired, + std::chrono::milliseconds delay) +{ + std::lock_guard lock(mutex_); + const Key identity{section, key}; + const auto saved = saved_.find(identity); + if (stopping_ || cancel_pending_.load() || saved == saved_.end()) + return 0; + pending_.insert_or_assign(identity, + Pending{++revision_, + {std::move(section), std::move(key), saved->second.value, std::move(desired)}, + std::chrono::steady_clock::now() + delay}); + saved->second.status = {revision_, saved->second.status.outcome, true, true}; + ++status_sequence_; + has_work_.store(true); + if (!worker_.joinable()) { + try { + worker_ = CONFIG_EDIT_START_WORKER(&RuntimeConfigWriter::Run, this); + } catch (...) { + pending_.clear(); + has_work_.store(false); + completion_ = {revision_, Outcome::IoError}; + saved->second.failed = true; + saved->second.status = {revision_, Outcome::IoError, false, true}; + ++status_sequence_; + has_failures_.store(true); + return 0; + } + } + wake_.notify_one(); + return revision_; +} + +void RuntimeConfigWriter::Stop(bool cancel_pending) +{ + if (cancel_pending) + RequestCancelPending(); + std::lock_guard lock(mutex_); + stopping_ = true; + if (cancel_pending && !pending_.empty()) { + completion_ = {revision_, Outcome::Cancelled}; + for (const auto& [identity, pending] : pending_) { + auto& saved = saved_.at(identity); + if (saved.status.revision == pending.revision) { + saved.status = {pending.revision, Outcome::Cancelled, false, true}; + saved.failed = true; + } + } + has_failures_.store(true); + ++status_sequence_; + pending_.clear(); + } + wake_.notify_one(); +} + +void RuntimeConfigWriter::RequestCancelPending() +{ + cancel_pending_.store(true); + wake_.notify_one(); +} + +RuntimeConfigWriter::Completion RuntimeConfigWriter::LastCompletion() +{ + std::lock_guard lock(mutex_); + return completion_; +} +bool RuntimeConfigWriter::HasFailure(std::string_view section, std::string_view key) +{ + std::lock_guard lock(mutex_); + const auto found = saved_.find(Key{std::string(section), std::string(key)}); + return found != saved_.end() && found->second.failed; +} +RuntimeConfigWriter::Status RuntimeConfigWriter::GetStatus(std::string_view section, std::string_view key) +{ + std::lock_guard lock(mutex_); + const auto found = saved_.find(Key{std::string(section), std::string(key)}); + return found == saved_.end() ? Status{} : found->second.status; +} + +void RuntimeConfigWriter::Run() +{ + for (;;) { + Pending work; + { + std::unique_lock lock(mutex_); + wake_.wait(lock, [&] { return stopping_ || cancel_pending_.load() || !pending_.empty(); }); + if (cancel_pending_.load()) { + stopping_ = true; + if (!pending_.empty()) { + completion_ = {revision_, Outcome::Cancelled}; + for (const auto& [identity, pending] : pending_) { + auto& saved = saved_.at(identity); + if (saved.status.revision == pending.revision) { + saved.status = {pending.revision, Outcome::Cancelled, false, true}; + saved.failed = true; + } + } + has_failures_.store(true); + ++status_sequence_; + } + pending_.clear(); + } + if (pending_.empty()) + break; + auto next = std::min_element(pending_.begin(), pending_.end(), + [](const auto& a, const auto& b) { return a.second.ready < b.second.ready; }); + if (!stopping_ && next->second.ready > std::chrono::steady_clock::now()) { + const auto deadline = next->second.ready; + wake_.wait_until(lock, deadline); + continue; // New submissions may move a deadline; orderly stop flushes it. + } + work = std::move(next->second); + pending_.erase(next); + } + Outcome outcome; + try { + outcome = CONFIG_EDIT_SAVE(editor_, path_, work.edit); + } catch (...) { + outcome = Outcome::IoError; + } + { + std::lock_guard lock(mutex_); + if (outcome == Outcome::Saved || outcome == Outcome::AlreadySaved) { + // Rebase our queued intent over our own successful write, never over a + // conflicting external edit. Failed saves leave the acknowledged value alone. + const Key identity{work.edit.section, work.edit.key}; + auto& saved = saved_.at(identity).value; + if (auto pending = pending_.find(identity); pending != pending_.end() && pending->second.edit.expected == saved) + pending->second.edit.expected = work.edit.desired; + saved = work.edit.desired; + } + // A successful B save must not hide an unsaved A change. + auto& saved_state = saved_.at(Key{work.edit.section, work.edit.key}); + auto& status = saved_state.status; + // A queued newer cancellation owns the final failure state; an older + // in-flight success must not make its unsaved value appear acknowledged. + if (status.revision == work.revision || status.pending) + saved_state.failed = outcome != Outcome::Saved && outcome != Outcome::AlreadySaved; + if (status.revision == work.revision) + status = {work.revision, outcome, false, true}; + ++status_sequence_; + has_failures_.store( + std::any_of(saved_.begin(), saved_.end(), [](const auto& entry) { return entry.second.failed; })); + if (work.revision >= completion_.revision) + completion_ = {work.revision, outcome}; + } + if (report_ && outcome != Outcome::Saved && outcome != Outcome::AlreadySaved) { + try { + report_(work.edit.section, work.edit.key, outcome); + } catch (...) { /* Diagnostics cannot kill the worker. */ + } + } + { + std::lock_guard lock(mutex_); + // A diagnostic callback is still active worker work, even after disk I/O. + has_work_.store(!pending_.empty()); + } + } + has_work_.store(false); + finished_.store(true); +} + +bool RuntimeConfigWriter::PollStopped() +{ + if (!worker_.joinable()) { + std::lock_guard lock(mutex_); + return stopping_; + } +#if _WIN32 + if (WaitForSingleObject(worker_.native_handle(), 0) != WAIT_OBJECT_0) + return false; +#else + // The worker has completed its disk work and published its final state. + if (!finished_.load()) + return false; +#endif + worker_.join(); + return true; +} + +#if _WIN32 +void* RuntimeConfigWriter::NativeHandle() +{ return worker_.joinable() ? worker_.native_handle() : nullptr; } +#endif +} // namespace config_edit diff --git a/mods/src/runtime_config_writer.h b/mods/src/runtime_config_writer.h new file mode 100644 index 000000000..e3992b5f1 --- /dev/null +++ b/mods/src/runtime_config_writer.h @@ -0,0 +1,85 @@ +#pragma once + +#include "toml_editor.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace config_edit +{ +// One owner for the configured file. Registration retains the startup semantic +// value, even when another key has already started the worker. +class RuntimeConfigWriter +{ +public: + using Reporter = void (*)(std::string_view, std::string_view, Outcome); + struct Completion { + std::uint64_t revision = 0; + Outcome outcome = Outcome::AlreadySaved; + }; + struct Status { + std::uint64_t revision = 0; + Outcome outcome = Outcome::AlreadySaved; + bool pending = false; + bool registered = false; + }; + explicit RuntimeConfigWriter(std::filesystem::path path, Reporter report = nullptr); + ~RuntimeConfigWriter(); // Tests/explicit owners only; game adapter has process lifetime. + // Idempotent for known keys: a repeated registration never rebases its value. + // Runtime submissions may update only keys registered by their definitions. + bool Register(std::string section, std::string key, std::optional initial); + std::uint64_t Submit(std::string section, std::string key, Value desired, std::chrono::milliseconds delay = {}); + void Stop(bool cancel_pending); + // Publish force-close cancellation before native deadline setup, without a lock. + void RequestCancelPending(); + Completion LastCompletion(); + Status GetStatus(std::string_view section, std::string_view key); + std::uint64_t StatusSequence() const + { return status_sequence_.load(); } + bool HasWork() const + { return has_work_.load(); } + bool HasFailures() const + { return has_failures_.load(); } + // Failure-path query: lets a caller distinguish a tracked failed attempt from + // an untracked rejection. A same-key retry can clear the former normally. + bool HasFailure(std::string_view section, std::string_view key); + // Owner thread only, like Submit. On Windows this observes native thread exit + // before joining; it never joins a still-running worker on a game callback. + bool PollStopped(); +#if _WIN32 + void* NativeHandle(); // Owner thread only; caller must duplicate before retaining. +#endif +private: + struct Pending { + std::uint64_t revision; + Request edit; + std::chrono::steady_clock::time_point ready; + }; + void Run(); + std::filesystem::path path_; + using Key = std::pair; + struct Saved { + std::optional value; + bool failed = false; + Status status; + }; + std::map saved_; + Reporter report_; + TomlEditor editor_; + std::mutex mutex_; + std::condition_variable wake_; + std::thread worker_; + std::map pending_; + Completion completion_; + std::uint64_t revision_ = 0; + bool stopping_ = false; + std::atomic_bool has_work_{false}, finished_{false}, cancel_pending_{false}; + std::atomic_bool has_failures_{false}; + std::atomic status_sequence_{0}; +}; +} // namespace config_edit diff --git a/mods/src/settings/action_setting.h b/mods/src/settings/action_setting.h new file mode 100644 index 000000000..6316089df --- /dev/null +++ b/mods/src/settings/action_setting.h @@ -0,0 +1,54 @@ +#pragma once +#include "setting_metadata.h" +#include +#include +#include +#include +#include + +namespace mod_settings +{ +// A command with a current presentation, not a persisted boolean. The native +// adapter owns each bound view; these definitions live with the page catalog. +struct ActionSetting { + struct Presentation { + std::string label, button, value; + bool enabled = true, visible = true; + bool actionable() const + { return visible && enabled && !button.empty(); } + }; + std::string identity, label; + std::function read; + std::function invoke; + // Optional repeated rows, evaluated only when building/refreshing settings. + // Definitions stay stable while their list grows or shrinks. + std::function count; + // Optional model operations for exact-entry rows. These refer to the same + // definition as sliders/toggles; commands without a setting leave them empty. + const SettingMetadata* metadata = nullptr; + std::function reset, retry; + std::function status, default_label; + std::function save_status; + std::function prepare_reset; + std::size_t Count() const + { return count ? count() : 1; } + Presentation Read(std::size_t index) const + { return index < Count() ? read(index) : Presentation{"", "", "", false, false}; } + std::string item_id(std::size_t index) const + { return identity + ".row." + std::to_string(index); } + std::optional item_index(std::string_view text) const + { + const auto prefix = identity + ".row."; + if (!text.starts_with(prefix)) + return {}; + text.remove_prefix(prefix.size()); + std::size_t index = 0; + const auto result = std::from_chars(text.data(), text.data() + text.size(), index); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size() || text != std::to_string(index)) + return {}; + return index; + } + const std::string& id() const + { return identity; } +}; +} // namespace mod_settings diff --git a/mods/src/settings/boolean_settings.h b/mods/src/settings/boolean_settings.h new file mode 100644 index 000000000..693135b83 --- /dev/null +++ b/mods/src/settings/boolean_settings.h @@ -0,0 +1,57 @@ +#pragma once +#include "value_settings.h" + +namespace mod_settings +{ +using ReadResult = ValueReadResult; +using Definition = ValueDefinition; +using Snapshot = ValueSnapshot; +using WriteResult = ValueWriteResult; +class BooleanSetting : public ValueSetting +{ +public: + using ValueSetting::ValueSetting; +}; + +enum class Registration { Added, Duplicate, Invalid, Frozen }; +class BooleanRegistry +{ +public: + Registration Register(Definition definition) + { + CheckThread(); + if (frozen_) + return Registration::Frozen; + if (definition.id.empty() || definition.label.empty() || !definition.read || !definition.write) + return Registration::Invalid; + if (settings_.contains(definition.id)) + return Registration::Duplicate; + auto id = definition.id; + settings_.try_emplace(std::move(id), std::move(definition)); + return Registration::Added; + } + BooleanSetting* Find(const std::string& id) + { + CheckThread(); + frozen_ = true; + auto it = settings_.find(id); + return it == settings_.end() ? nullptr : &it->second; + } + void InvalidateSession() + { + CheckThread(); + for (auto& [id, setting] : settings_) + setting.InvalidateSession(); + } + +private: + void CheckThread() const + { + if (std::this_thread::get_id() != thread_) + throw std::logic_error("registry thread mismatch"); + } + const std::thread::id thread_ = std::this_thread::get_id(); + bool frozen_ = false; + std::map settings_; +}; +} // namespace mod_settings diff --git a/mods/src/settings/boolean_view.h b/mods/src/settings/boolean_view.h new file mode 100644 index 000000000..9ace78ea8 --- /dev/null +++ b/mods/src/settings/boolean_view.h @@ -0,0 +1,7 @@ +#pragma once +#include "boolean_settings.h" +#include "value_view.h" +namespace mod_settings +{ +using BooleanView = ValueView; +} diff --git a/mods/src/settings/choice_setting.h b/mods/src/settings/choice_setting.h new file mode 100644 index 000000000..ea108538e --- /dev/null +++ b/mods/src/settings/choice_setting.h @@ -0,0 +1,49 @@ +#pragma once +#include "value_view.h" +#include + +namespace mod_settings +{ +class ChoiceSetting +{ +public: + ChoiceSetting(ValueDefinition definition, std::vector labels) + : labels_(std::move(labels)) + , state_(Checked(std::move(definition), labels_.size())) + { + for (const auto& label : labels_) + if (label.empty()) + throw std::invalid_argument("choice label"); + } + ValueSetting& state() + { return state_; } + const std::vector& labels() const + { return labels_; } + std::string item_id(int index) const + { return state_.id() + "." + std::to_string(index); } + +private: + static ValueDefinition Checked(ValueDefinition definition, std::size_t count) + { + if (count < 2 || count > 16 || definition.id.empty() || definition.label.empty() || !definition.read + || !definition.write) + throw std::invalid_argument("choice setting definition"); + auto read = std::move(definition.read); + auto write = std::move(definition.write); + definition.read = [read = std::move(read), count] { + auto result = read(); + if (result.known() && (*result.value < 0 || static_cast(*result.value) >= count)) + return ValueReadResult{}; + return result; + }; + definition.write = [write = std::move(write), count](int value, std::uint64_t generation) { + if (value < 0 || static_cast(value) >= count) + return ApplyResult::Rejected; + return write(value, generation); + }; + return definition; + } + std::vector labels_; + ValueSetting state_; +}; +} // namespace mod_settings diff --git a/mods/src/settings/forbidden_tech.cc b/mods/src/settings/forbidden_tech.cc new file mode 100644 index 000000000..25584559f --- /dev/null +++ b/mods/src/settings/forbidden_tech.cc @@ -0,0 +1,26 @@ +#include "forbidden_tech.h" +#include "config.h" +#include "defaultconfig.h" +#include "persisted_setting.h" + +namespace mod_settings +{ +BooleanSetting& ForbiddenTechConfirmationSetting() +{ + static BooleanSetting setting(PersistedDefinition( + {"community_mod.ft_confirmation", "Confirm Forbidden Tech upgrades", + [] { + return ForbiddenTechControlsAvailable() ? ReadResult::Known(!Config::Get().auto_confirm_ft_upgrade, 1) + : ReadResult{}; + }, + [](bool confirm, std::uint64_t generation) { + if (generation != 1 || !ForbiddenTechControlsAvailable()) + return ApplyResult::Rejected; + Config::Get().auto_confirm_ft_upgrade = !confirm; + return ApplyResult::Applied; + }}, + {"ui", "auto_confirm_ft_upgrade", "Ask before upgrading Forbidden Tech.", "", ApplyTiming::Live}, + !DefaultConfig::UI::auto_confirm_ft_upgrade, [](const bool& confirm) -> config_edit::Value { return !confirm; })); + return setting; +} +} // namespace mod_settings diff --git a/mods/src/settings/forbidden_tech.h b/mods/src/settings/forbidden_tech.h new file mode 100644 index 000000000..9d93795cc --- /dev/null +++ b/mods/src/settings/forbidden_tech.h @@ -0,0 +1,7 @@ +#pragma once +#include "boolean_settings.h" +namespace mod_settings +{ +BooleanSetting& ForbiddenTechConfirmationSetting(); +} +bool ForbiddenTechControlsAvailable(); diff --git a/mods/src/settings/maintenance.h b/mods/src/settings/maintenance.h new file mode 100644 index 000000000..cc270b37f --- /dev/null +++ b/mods/src/settings/maintenance.h @@ -0,0 +1,175 @@ +#pragma once + +#include "page_catalog.h" +#include "text_entry.h" +#include +#include + +namespace mod_settings +{ +// A companion page keeps help/default commands separate from the normal native +// controls. Every command refers back to its model, including exact-entry rows. +inline void RegisterMaintenancePages(PageCatalog& catalog) +{ + struct Control { + const SettingMetadata* metadata; + std::string label; + std::function prepare_reset; + std::function retry; + std::function status, default_label; + std::function save_status; + }; + static std::vector> commands; + const auto pages = catalog.RegisteredPages(); + for (const auto& page : pages) { + if (page.metadata) + continue; // Shortcut pages own their draft/default workflow. + auto controls = std::make_shared>(); + std::set seen; + for (const auto& item : page.items) + std::visit( + [&](const auto& value) { + using T = std::decay_t; + if constexpr (!std::is_same_v) { + Control control; + if constexpr (std::is_same_v) { + if (!value->metadata || !value->prepare_reset) + return; + control = {value->metadata, value->label, value->prepare_reset, value->retry, + value->status, value->default_label, value->save_status}; + } else { + auto* setting = [&] { + if constexpr (std::is_same_v) + return value; + else + return &value->state(); + }(); + if (!setting->default_value()) + return; + control = {&setting->metadata(), + setting->label(), + [setting] { return PrepareReset(*setting); }, + [setting] { setting->RetrySave(setting->Observe()); }, + [setting] { return setting->status_text(); }, + [setting, value] { + const auto& defaultValue = *setting->default_value(); + if constexpr (std::is_same_v) + return std::string(defaultValue ? "On" : "Off"); + else if constexpr (std::is_same_v) + return value->labels().at(defaultValue); + else + return std::to_string(defaultValue); + }, + [setting] { return setting->save_status(); }}; + } + const auto key = control.metadata->TomlKey(); + if (!key.empty()) { + if (seen.insert(key).second) + controls->push_back(std::move(control)); + else if constexpr (std::is_same_v) { + // An exact editor owns the full configured range. Prefer its + // canonical model over a convenience slider, in either order. + const auto existing = std::ranges::find_if( + *controls, [&](const Control& entry) { return entry.metadata->TomlKey() == key; }); + if (existing != controls->end()) + *existing = std::move(control); + } + } + } + }, + item); + if (controls->empty()) + continue; + const auto id = page.id + ".defaults"; + catalog.AddPage(id, "Defaults & Help", page.id); + const auto add = [&](std::shared_ptr action) { + catalog.AddAction(id, *action); + commands.push_back(std::move(action)); + }; + auto all = std::make_shared(); + all->identity = id + ".reset"; + all->label = "Restore page defaults"; + all->read = [label = page.label](std::size_t) { + return ActionSetting::Presentation{"Restore page defaults", "Review", label}; + }; + all->invoke = [controls, label = page.label](std::size_t) { + std::vector plans; + std::size_t skipped = 0; + for (const auto& control : *controls) { + auto plan = control.prepare_reset(); + if (plan.current && plan.current()) + plans.push_back(std::move(plan)); + else + ++skipped; + } + auto help = "Reset " + std::to_string(plans.size()) + " available setting(s) on this page."; + if (skipped) + help += " " + std::to_string(skipped) + " unavailable setting(s) will be skipped."; + help += " Each change is saved to TOML. Included settings must remain available and unchanged while this " + "confirmation is open. Restart settings take effect after restarting the client."; + OpenTextEntry({.title = "Restore " + label + " defaults", + .value = "", + .help = std::move(help), + .commit = + [plans = std::move(plans)](std::string_view) { + if (plans.empty()) + return false; + for (const auto& plan : plans) + if (!plan.current || !plan.current()) + return false; + for (const auto& plan : plans) + if (!plan.apply || !plan.apply()) + return false; + return true; + }, + .read_only = true, + .apply_label = "Reset page"}); + }; + add(std::move(all)); + for (const auto& control : *controls) { + auto details = std::make_shared(); + details->identity = id + "." + control.metadata->section + "." + control.metadata->key; + details->label = control.label; + details->read = [control](std::size_t) { + return ActionSetting::Presentation{control.label, "Details", control.status ? control.status() : ""}; + }; + details->invoke = [control](std::size_t) { + auto plan = control.prepare_reset(); + std::string help = control.metadata->TomlKey() + "\n" + control.metadata->description; + const auto defaultValue = control.default_label ? control.default_label() : std::string{}; + help += "\nDefault: " + (defaultValue.empty() ? std::string("(empty)") : defaultValue); + if (control.metadata->timing == ApplyTiming::Restart) + help += "\nTakes effect after restarting the client."; + else if (control.metadata->timing == ApplyTiming::ReopenView) + help += "\nReopen the affected screen after changing this setting."; + else + help += "\nTakes effect immediately."; + OpenTextEntry( + {.title = control.label, + .value = "", + .help = std::move(help), + .commit = [plan = std::move(plan)]( + std::string_view) { return plan.current && plan.current() && plan.apply && plan.apply(); }, + .read_only = true, + .apply_label = "Reset default"}); + }; + add(std::move(details)); + auto retry = std::make_shared(); + retry->identity = id + "." + control.metadata->section + "." + control.metadata->key + ".retry"; + retry->label = control.label + " save"; + retry->read = [control](std::size_t) { + const auto status = control.status ? control.status() : std::string{}; + const auto state = control.save_status ? control.save_status().state : SaveState::None; + const bool failed = + state == SaveState::Conflict || state == SaveState::Failed || state == SaveState::Unavailable; + return ActionSetting::Presentation{control.label, "Retry save", status, !!control.retry, failed}; + }; + retry->invoke = [control](std::size_t) { + if (control.retry) + control.retry(); + }; + add(std::move(retry)); + } + } +} +} // namespace mod_settings diff --git a/mods/src/settings/mod_pages.cc b/mods/src/settings/mod_pages.cc new file mode 100644 index 000000000..c79c90128 --- /dev/null +++ b/mods/src/settings/mod_pages.cc @@ -0,0 +1,19 @@ +#include "mod_pages.h" +#include "shortcut_settings.h" +#include "upstream_settings.h" + +namespace mod_settings +{ +PageCatalog& ModPages() +{ + static PageCatalog catalog("community_mod.settings", "Mod Settings"); + return catalog; +} + +void RegisterModPages() +{ + auto& catalog = ModPages(); + RegisterUpstreamSettings(catalog); + RegisterShortcutPages(catalog); +} +} // namespace mod_settings diff --git a/mods/src/settings/mod_pages.h b/mods/src/settings/mod_pages.h new file mode 100644 index 000000000..f7db55e07 --- /dev/null +++ b/mods/src/settings/mod_pages.h @@ -0,0 +1,10 @@ +#pragma once +#include "page_catalog.h" + +namespace mod_settings +{ +// Register intentional feature adapters before native settings installation. +// The empty catalog installs no extra navigation hooks and shows no empty menu. +PageCatalog& ModPages(); +void RegisterModPages(); +} // namespace mod_settings diff --git a/mods/src/settings/native/action_widgets.cc b/mods/src/settings/native/action_widgets.cc new file mode 100644 index 000000000..5a1c987e1 --- /dev/null +++ b/mods/src/settings/native/action_widgets.cc @@ -0,0 +1,439 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "action_widgets.h" +#include "page_navigation.h" +#include "row_style.h" +#include "settings/native_boolean_callback.h" +#include "settings/shortcut_popup.h" +#include "settings/shortcut_settings.h" +#include +#include +#include + +namespace mod_settings::native +{ +namespace +{ + NativeCallback actionCallback; + NativeCallback actionGetter; + bool actionsActive = false; + Il2CppObject* invokingWidget = nullptr; +} // namespace +Il2CppObject* InvokingActionWidget() +{ return invokingWidget; } +bool ActionsActive() +{ return actionsActive; } +struct ActionMetadata { + IL2CppClassHelper widget = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "ButtonAndTextOptionWidget"); + IL2CppClassHelper row = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "ButtonAndTextOptionContext"); + const MethodInfo* add = ToggleMeta().context.GetMethodInfo("AddButtonAndText", 6); + const MethodInfo* refresh = widget.GetMethodInfo("SetWidgetData", 0); + const MethodInfo* release = widget.GetMethodInfo("OnAboutToReleaseContext", 0); + const MethodInfo* getContext = widget.GetMethodInfo("get_Context", 0); + FieldInfo* label = Field(widget.get_cls(), "_label"); + FieldInfo* buttonLabel = Field(widget.get_cls(), "_buttonLabel"); + FieldInfo* valueLabel = Field(widget.get_cls(), "_valueLabel"); + FieldInfo* button = Field(widget.get_cls(), "_button"); +}; +ActionMetadata& ActionMeta() +{ + static ActionMetadata metadata; + return metadata; +} + +Il2CppObject* ActionToken(Il2CppObject* context) +{ + if (!context || context->klass != ActionMeta().row.get_cls()) + return nullptr; + Root safe(Call(context, "get_Callback")); + if (!safe.get()) + return nullptr; + Root callbacks(ReadField(safe.get(), Field(safe.get()->klass, "_callbacks"))); + if (Count(callbacks.get()) != 1) + return nullptr; + auto* callback = reinterpret_cast(Item(callbacks.get(), 0)); + return callback && callback->method == actionCallback.method() + && callback->method_ptr == actionCallback.method()->methodPointer + ? callback->target + : nullptr; +} +ActionRow ActionFor(Il2CppObject* context) +{ + if (!ActionToken(context)) + return {}; + Root parent(Call(context, "get_Parent")); + const auto* page = PageFor(parent.get()); + if (!page) + return {}; + Root label(Call(context, "get_LabelContext")); + Root identifier(Call(label.get(), "get_Identifier")); + if (!identifier.get() || !Type(il2cpp_class_get_type(identifier.get()->klass), IL2CPP_TYPE_STRING)) + return {}; + auto* text = reinterpret_cast(identifier.get()); + std::string id; + for (int i = 0; i < il2cpp_string_length(text); ++i) { + const auto character = il2cpp_string_chars(text)[i]; + if (character > 127) + return {}; + id += static_cast(character); + } + for (auto* action : page->Controls()) + if (const auto index = action->item_index(id)) + return {action, *index}; + return {}; +} +struct ActionView { + Il2CppGCHandle widget = nullptr, context = nullptr, token = nullptr; + std::array labels{}; + Il2CppGCHandle button = nullptr; + Il2CppGCHandle buttonObject = nullptr; + ActionSetting* action = nullptr; + std::size_t index = 0; + bool buttonHidden = false, buttonBefore = false; + bool rendering = false, invoking = false; +}; +std::deque actionViews; +void ClearAction(ActionView& view) +{ + view.action = nullptr; + for (auto& handle : view.labels) { + if (auto* label = Target(handle)) + ClearRowText(label); + Free(handle); + } + try { + if (auto* button = Target(view.button)) + Call(button, "ClearInteractable"); + if (view.buttonHidden) + SetActive(Target(view.buttonObject), view.buttonBefore); + } catch (...) { + Warn("command view restoration unavailable"); + } + Free(view.widget); + Free(view.context); + Free(view.token); + Free(view.button); + Free(view.buttonObject); + view.buttonHidden = false; +} +void RenderAction(ActionView& view) +{ + if (!view.action || view.rendering) + return; + struct Scope { + bool& value; + Scope(bool& v) + : value(v) + { value = true; } + ~Scope() + { value = false; } + } scope(view.rendering); + Root widget(Target(view.widget)), context(Target(view.context)); + if (!widget.get() || !context.get() || Invoke(ActionMeta().getContext, widget.get()) != context.get()) { + ClearAction(view); + return; + } + auto* action = view.action; + const auto index = view.index; + const auto presentation = action->Read(index); + if (view.action != action || view.index != index || Target(view.context) != context.get() + || Invoke(ActionMeta().getContext, widget.get()) != context.get()) + return; + const std::array text{presentation.label, presentation.button, presentation.value}; + for (std::size_t i = 0; i < text.size(); ++i) { + auto* label = Target(view.labels[i]); + SetRowText(label, label, text[i]); + } + if (view.buttonHidden) { + SetActive(Target(view.buttonObject), view.buttonBefore); + view.buttonHidden = false; + } + if (presentation.button.empty()) { + view.buttonBefore = Il2CppChecked::Boolean(Call(Target(view.buttonObject), "get_activeSelf")); + view.buttonHidden = true; + SetActive(Target(view.buttonObject), false); + } + bool enabled = presentation.actionable(); + void* args[] = {&enabled}; + Call(Target(view.button), "OverrideInteractable", 1, args); +} +void RefreshActions() +{ + if (!OnUIThread() || !actionsActive || !PagesActive() || PageRefreshInProgress()) + return; + + RefreshPageRows(); + RefreshPageSummaries(); + RefreshActionPresentations(); +} +void RefreshActionPresentations() +{ + if (!OnUIThread() || !actionsActive || !PagesActive() || PageRefreshInProgress()) + return; + // Binding during a callback may append a deque slot. References stay valid; + // iterators do not, so visit only the slots that existed at entry. + for (std::size_t i = 0, count = actionViews.size(); i < count; ++i) { + auto& view = actionViews[i]; + try { + RenderAction(view); + } catch (...) { + ClearAction(view); + Warn("settings command presentation unavailable"); + } + } +} +void InvokeAction(Il2CppObject* token, const MethodInfo*) +{ + if (!OnUIThread() || !actionsActive || !PagesActive() || PageRefreshInProgress()) + return; + try { + for (auto& view : actionViews) { + if (!view.action || view.rendering || view.invoking || Target(view.token) != token) + continue; + struct Scope { + bool& flag; + Scope(bool& value) + : flag(value) + { flag = true; } + ~Scope() + { flag = false; } + } scope(view.invoking); + auto* action = view.action; + const auto index = view.index; + Root widget(Target(view.widget)), context(Target(view.context)); + if (!widget.get() || !context.get() || Invoke(ActionMeta().getContext, widget.get()) != context.get() + || ActionToken(context.get()) != token) + return; + // EventSystem can submit a focused button with Enter/Space while capture + // is active. The command's current availability is authoritative too. + const bool enabled = action->Read(index).actionable(); + // A feature-owned reader may release/rebind its row. Keep that callback + // from authorizing a different command or recursively invoking itself. + if (enabled && view.action == action && view.index == index && Target(view.context) == context.get() + && Target(view.token) == token && Invoke(ActionMeta().getContext, widget.get()) == context.get()) { + struct Invocation { + Il2CppObject* previous; + explicit Invocation(Il2CppObject* widget) + : previous(invokingWidget) + { invokingWidget = widget; } + ~Invocation() + { invokingWidget = previous; } + } invocation(widget.get()); + action->invoke(index); + } + return; + } + } catch (...) { + Warn("settings command unavailable"); + } +} +void ActionRefreshHook(auto original, Il2CppObject* widget) +{ + if (OnUIThread()) + for (std::size_t i = 0, count = actionViews.size(); i < count; ++i) { + auto& view = actionViews[i]; + if (Target(view.widget) == widget) { + // Pooling/refresh only releases the widget. The page owns its editor. + ClearAction(view); + } + } + original(widget); + if (!OnUIThread() || !actionsActive || !PagesActive()) + return; + ActionView* tracked = nullptr; + try { + Root context(Invoke(ActionMeta().getContext, widget)); + const auto action = ActionFor(context.get()); + if (!action.first) + return; + for (auto& view : actionViews) + if (!view.action && !view.rendering && !view.invoking) { + tracked = &view; + break; + } + if (!tracked) { + actionViews.emplace_back(); + tracked = &actionViews.back(); + } + auto& view = *tracked; + view.action = action.first; + view.index = action.second; + auto weak = [](Il2CppObject* object) { + const auto handle = object ? il2cpp_gchandle_new_weakref(object, false) : nullptr; + if (!handle) + throw std::runtime_error("settings command weak root"); + return handle; + }; + view.widget = weak(widget); + view.context = weak(context.get()); + view.token = weak(ActionToken(context.get())); + const std::array fields{ActionMeta().label, ActionMeta().buttonLabel, ActionMeta().valueLabel}; + for (std::size_t i = 0; i < fields.size(); ++i) + view.labels[i] = weak(ReadField(widget, fields[i])); + view.button = weak(ReadField(widget, ActionMeta().button)); + Root buttonObject(Call(Target(view.button), "get_gameObject")); + view.buttonObject = weak(buttonObject.get()); + RenderAction(view); + } catch (...) { + if (tracked) + ClearAction(*tracked); + Warn("settings command binding unavailable"); + } +} +void ActionReleaseHook(auto original, Il2CppObject* widget) +{ + if (OnUIThread()) + for (std::size_t i = 0, count = actionViews.size(); i < count; ++i) { + auto& view = actionViews[i]; + if (Target(view.widget) == widget) + ClearAction(view); + } + original(widget); +} +void AddActionRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, ActionSetting& action, + std::size_t index) +{ + if (!actionsActive) + return; + const auto* add = ActionMeta().add; + Root children(Call(parent, "get_Children")); + const int before = Count(children.get()); + if (before == PageCatalog::NativeChildLimit) + throw std::runtime_error("settings command capacity"); + Root label(reinterpret_cast(il2cpp_string_new(action.item_id(index).c_str()))); + Root empty(reinterpret_cast(il2cpp_string_new(""))); + // A fresh plain Object is the closed callback's identity, owned by this native + // context. Old pooled-row events cannot invoke a later context's command. + Root token(il2cpp_object_new(il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"))); + Root callback(MakeDelegate(il2cpp_class_from_type(add->parameters[3]), token.get(), actionCallback.method())); + Root get(MakeDelegate(il2cpp_class_from_type(add->parameters[4]), director, actionGetter.method())); + void* args[] = {parent, label.get(), empty.get(), callback.get(), get.get(), empty.get()}; + Invoke(add, context, args); + if (Count(children.get()) != before + 1) + throw std::runtime_error("settings command insertion"); + Root row(Item(children.get(), before)); + if (ActionFor(row.get()) != ActionRow{&action, index} || ActionToken(row.get()) != token.get()) + throw std::runtime_error("settings command identity"); +} +void SyncActionRows(Il2CppObject* controller, Il2CppObject* context, const PageCatalog::Page& page) +{ + if (!actionsActive) + return; + auto actions = page.Controls(); + if (actions.empty()) + return; + Root children(Call(context, "get_Children")); + const auto size = Count(children.get()); + std::vector existing, missing; + for (int i = 0; i < size; ++i) + if (const auto row = ActionFor(Item(children.get(), i)); row.first) + existing.push_back(row); + for (auto* action : actions) + for (std::size_t index = 0, count = action->Count(); index < count; ++index) { + const ActionRow row{action, index}; + if (std::find(existing.begin(), existing.end(), row) != existing.end()) + continue; + // The adapter already bounds every native child list at 128. Check the + // full addition before mutating it, including rows retained after removal. + if (size + missing.size() == PageCatalog::NativeChildLimit) + throw std::runtime_error("settings command capacity"); + missing.push_back(row); + } + if (missing.empty()) + return; + Root callbackObject(ReadField(context, ToggleMeta().queryField)); + auto* callback = reinterpret_cast(callbackObject.get()); + if (!callback || callback->method != QueryMethod() || callback->method_ptr != QueryMethod()->methodPointer + || !callback->target || callback->target->klass != ToggleMeta().director.get_cls()) + throw std::runtime_error("settings command page owner"); + Root director(callback->target); + Root canvas(Call(controller, "get_CanvasContext")); + Root selected(Call(canvas.get(), "get_SelectedOption")); + if (!canvas.get() || canvas.get()->klass != ToggleMeta().context.get_cls() || selected.get() != context) + throw std::runtime_error("settings command page changed"); + // Contexts remain owned by the native page up to its largest binding count. + // Shrinking hides surplus indices; later additions reuse those same contexts. + for (const auto& [action, index] : missing) + AddActionRow(director.get(), canvas.get(), context, *action, index); +} +void InstallActionWidgets() +{ + auto& m = PageMeta(); + if (std::any_of(Pages().begin(), Pages().end(), + [](const auto& page) { return !page.template Controls().empty(); })) { + try { + auto& action = ActionMeta(); + auto* objects = il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"); + const auto* clickSchema = objects ? il2cpp_class_get_method_from_name(objects, ".ctor", 0) : nullptr; + const auto* getSchema = ToggleMeta().director.GetMethodInfo("GetClientVersion", 0); + if (!Instance(action.add, 6, IL2CPP_TYPE_VOID) || !Reference(action.add->parameters[0]) + || !Type(action.add->parameters[1], IL2CPP_TYPE_STRING) + || !Type(action.add->parameters[2], IL2CPP_TYPE_STRING) || !Reference(action.add->parameters[3]) + || !Reference(action.add->parameters[4]) || !Type(action.add->parameters[5], IL2CPP_TYPE_STRING)) + throw std::runtime_error("SettingsContext.AddButtonAndText: expected instance Void(reference, String, String, " + "reference, reference, String)"); + if (!action.getContext || !Reference(action.getContext->return_type) + || !Instance(action.getContext, 0, action.getContext->return_type->type)) + throw std::runtime_error("ButtonAndTextOptionWidget.get_Context: expected instance reference result"); + if (!Instance(clickSchema, 0, IL2CPP_TYPE_VOID)) + throw std::runtime_error("System.Object..ctor: expected instance Void() callback donor"); + if (!Instance(getSchema, 0, IL2CPP_TYPE_STRING)) + throw std::runtime_error("SettingsSectionDirector.GetClientVersion: expected instance String()"); + if (!actionCallback.Initialize(clickSchema, InvokeAction)) + throw std::runtime_error("command callback: incompatible native callback schema"); + if (!actionGetter.Initialize(getSchema, EmptyHeadingValue)) + throw std::runtime_error("command text getter: incompatible native callback schema"); + // build261 x64 GameAssembly 487af4bb: SetWidgetData CFD5F0..CFD8A5 (693) + // and OnAboutToReleaseContext CFD430..CFD53F (271), vs SPUD's 24 bytes. + // Resolve current addresses through managed metadata. + const std::array targets{action.refresh, action.release}; + const std::array names{"ButtonAndTextOptionWidget.SetWidgetData", + "ButtonAndTextOptionWidget.OnAboutToReleaseContext"}; + for (std::size_t i = 0; i < targets.size(); ++i) + if (!Instance(targets[i], 0, IL2CPP_TYPE_VOID)) + throw std::runtime_error(std::string(names[i]) + ": expected instance Void()"); + if (targets[0]->methodPointer == targets[1]->methodPointer) + throw std::runtime_error( + "ButtonAndTextOptionWidget.SetWidgetData and OnAboutToReleaseContext share a native hook target"); + for (auto* target : targets) { + for (auto* existing : + {ToggleMeta().refresh, ToggleMeta().changed, ToggleMeta().release, ToggleMeta().addGeneral, + ToggleMeta().reload, ToggleMeta().session, ToggleMeta().load, m.bind, m.release, m.selected, m.destroyed}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string("ButtonAndTextOptionWidget.") + target->name + " shares target with " + + existing->klass->name + "." + existing->name); + if (SelectionActive()) + for (auto* existing : {SelectionMeta().refresh, SelectionMeta().changed, SelectionMeta().release}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string("ButtonAndTextOptionWidget.") + target->name + " shares target with " + + existing->klass->name + "." + existing->name); + if (SliderActive()) + for (auto* existing : + {SliderMeta().refresh, SliderMeta().changed, SliderMeta().release, SliderMeta().valueLabel}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string("ButtonAndTextOptionWidget.") + target->name + " shares target with " + + existing->klass->name + "." + existing->name); + if (HeadingsActive()) + for (auto* existing : {HeadingMeta().refresh, HeadingMeta().clear}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string("ButtonAndTextOptionWidget.") + target->name + " shares target with " + + existing->klass->name + "." + existing->name); + } + ValidateHookFit(action.refresh); + if (!SPUD_STATIC_DETOUR(action.refresh->methodPointer, ActionRefreshHook)) + throw std::runtime_error("ButtonAndTextOptionWidget.SetWidgetData: hook installation failed"); + ValidateHookFit(action.release); + if (!SPUD_STATIC_DETOUR(action.release->methodPointer, ActionReleaseHook)) + throw std::runtime_error("ButtonAndTextOptionWidget.OnAboutToReleaseContext: hook installation failed"); + actionsActive = true; + InstallShortcutPopup(); + SetShortcutPresentationObserver(RefreshActions); + } catch (const std::exception&) { + Warn("settings command installation unavailable"); + } + } +} + +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/action_widgets.h b/mods/src/settings/native/action_widgets.h new file mode 100644 index 000000000..7112d9cf7 --- /dev/null +++ b/mods/src/settings/native/action_widgets.h @@ -0,0 +1,22 @@ +#pragma once + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "interop.h" +#include "settings/page_catalog.h" + +namespace mod_settings::native +{ +using ActionRow = std::pair; +bool ActionsActive(); +// Valid only during a synchronous action invocation; never retain this pointer. +Il2CppObject* InvokingActionWidget(); +void RefreshActions(); +void RefreshActionPresentations(); +ActionRow ActionFor(Il2CppObject* context); +void AddActionRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, ActionSetting& action, + std::size_t index); +void SyncActionRows(Il2CppObject* controller, Il2CppObject* context, const PageCatalog::Page& page); +void InstallActionWidgets(); +} // namespace mod_settings::native + +#endif diff --git a/mods/src/settings/native/interop.cc b/mods/src/settings/native/interop.cc new file mode 100644 index 000000000..2ff586958 --- /dev/null +++ b/mods/src/settings/native/interop.cc @@ -0,0 +1,188 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "interop.h" +#include "patches/native_hook_extent.h" +#include "settings/page_catalog.h" +#include +#include +#include +#include +#include +#include +#include + +namespace mod_settings::native +{ +void Warn(const char* operation) +{ + std::string message(operation); + if (auto exception = std::current_exception()) { + try { + std::rethrow_exception(exception); + } catch (const std::exception& error) { + message += ": "; + message += error.what(); + } catch (...) { + message += ": non-standard C++ exception"; + } + } + static std::mutex mutex; + static std::unordered_set reported; + { + std::lock_guard lock(mutex); + if (!reported.insert(message).second) + return; + } + spdlog::warn("[ModSettings] {}", message); +} + +bool Type(const Il2CppType* type, int expected) +{ return type && !type->byref && type->type == expected; } + +bool Reference(const Il2CppType* type) +{ + return Type(type, IL2CPP_TYPE_CLASS) || Type(type, IL2CPP_TYPE_GENERICINST) || Type(type, IL2CPP_TYPE_OBJECT) + || Type(type, IL2CPP_TYPE_STRING); +} + +bool Instance(const MethodInfo* method, int count, int result) +{ + return method && method->methodPointer && method->invoker_method && !(method->flags & METHOD_ATTRIBUTE_STATIC) + && method->parameters_count == count && Type(method->return_type, result) + && !method->has_full_generic_sharing_signature; +} + +void ValidateHookFit(const MethodInfo* method) +{ + if (!method || !method->methodPointer) + throw std::runtime_error("settings hook target missing"); +#if __APPLE__ + // Validate the actual loaded ARM64/x86_64 method boundary before SPUD writes + // its prologue. A successful build does not establish native hook fit. + if (!native_hooks::MacHookFits(reinterpret_cast(method->methodPointer))) + throw std::runtime_error(std::string(method->klass->name) + "." + method->name + + ": native hook extent unavailable"); +#endif +} + +FieldInfo* Field(Il2CppClass* cls, const char* name) +{ + auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; + if (!field || !field->type || !Reference(field->type) || (field->type->attrs & FIELD_ATTRIBUTE_STATIC)) { + const std::string member = + std::string(cls ? cls->namespaze : "") + "." + (cls ? cls->name : "") + "." + name; + throw std::runtime_error(member + ": expected instance reference field; " + + (!field ? "field missing" + : !field->type ? "type missing" + : "actual type=" + std::to_string(field->type->type) + ", static=" + + std::to_string(bool(field->type->attrs & FIELD_ATTRIBUTE_STATIC)) + + ", byref=" + std::to_string(field->type->byref))); + } + return field; +} +Il2CppObject* ReadField(Il2CppObject* object, FieldInfo* field) +{ + Il2CppObject* value = nullptr; + if (object) + il2cpp_field_get_value(object, field, &value); + return value; +} +Il2CppObject* Invoke(const MethodInfo* method, Il2CppObject* object, void** args) +{ + if (!method || !object) + throw std::runtime_error(std::string("settings invocation: ") + (method ? method->klass->name : "") + + "." + (method ? method->name : "?") + (object ? "" : ": missing receiver")); + Il2CppObject* result = nullptr; + if (!Il2CppRuntime::TryInvoke(method, object, args, &result)) + throw std::runtime_error(std::string(method->klass->name) + "." + method->name + ": managed invocation failed"); + return result; +} +// Bounded discovery helpers used only while opening a page or binding a row. +Il2CppObject* Call(Il2CppObject* object, const char* name, int count, void** args) +{ + auto* method = object ? IL2CppClassHelper(object->klass).GetMethodInfo(name, count) : nullptr; + if (!method) + throw std::runtime_error(std::string(object ? object->klass->name : "") + "." + name + "(" + + std::to_string(count) + " arguments): method unavailable"); + return Invoke(method, object, args); +} +bool Equals(Il2CppObject* value, const char* ascii) +{ + if (!value || !Type(il2cpp_class_get_type(value->klass), IL2CPP_TYPE_STRING)) + return false; + auto* text = reinterpret_cast(value); + const auto length = std::strlen(ascii); + if (il2cpp_string_length(text) != length) + return false; + auto* chars = il2cpp_string_chars(text); + for (std::size_t i = 0; i < length; ++i) + if (chars[i] != static_cast(ascii[i])) + return false; + return true; +} +Il2CppObject* Target(Il2CppGCHandle handle) +{ return handle ? il2cpp_gchandle_get_target(handle) : nullptr; } +void Free(Il2CppGCHandle& handle) +{ + if (handle) + il2cpp_gchandle_free(handle); + handle = nullptr; +} + +// Cosmetic changes belong to the bound row. Restore before native refresh or +void SetActive(Il2CppObject* object, bool value) +{ + void* args[] = {&value}; + Call(object, "SetActive", 1, args); +} +Il2CppObject* MakeDelegate(Il2CppClass* cls, Il2CppObject* director, const MethodInfo* method) +{ + const auto* ctor = cls ? il2cpp_class_get_method_from_name(cls, ".ctor", 2) : nullptr; + if (!Instance(ctor, 2, IL2CPP_TYPE_VOID) || !Reference(ctor->parameters[0]) + || !Type(ctor->parameters[1], IL2CPP_TYPE_I) || !method) + throw std::runtime_error("settings delegate constructor"); + const auto* invoke = il2cpp_class_get_method_from_name(cls, "Invoke", method->parameters_count); + auto* parent = il2cpp_class_get_parent(cls); + if (!parent || std::strcmp(il2cpp_class_get_name(parent), "MulticastDelegate") != 0 + || std::strcmp(il2cpp_class_get_namespace(parent), "System") != 0 || !invoke || invoke->return_type->byref + || (invoke->flags & METHOD_ATTRIBUTE_STATIC) + || il2cpp_class_from_type(invoke->return_type) != il2cpp_class_from_type(method->return_type) + || !il2cpp_class_is_assignable_from(method->klass, director->klass)) + throw std::runtime_error("settings delegate signature"); + for (int i = 0; i < method->parameters_count; ++i) + if (invoke->parameters[i]->byref + || il2cpp_class_from_type(invoke->parameters[i]) != il2cpp_class_from_type(method->parameters[i])) + throw std::runtime_error("settings delegate parameter"); + Root object(il2cpp_object_new(cls)); + void* args[] = {director, &method}; + Invoke(ctor, object.get(), args); + auto* delegate = reinterpret_cast(object.get()); + if (delegate->target != director || delegate->invoke_impl_this != director || delegate->method != method) + throw std::runtime_error("settings closed delegate"); + delegate->method_ptr = method->methodPointer; + delegate->invoke_impl = method->methodPointer; + return object.get(); +} + +int Count(Il2CppObject* list) +{ + Root value(Call(list, "get_Count")); + if (!value.get() || !Type(il2cpp_class_get_type(value.get()->klass), IL2CPP_TYPE_I4)) + throw std::runtime_error("settings child count"); + const int count = *static_cast(il2cpp_object_unbox(value.get())); + if (count < 0 || count > PageCatalog::NativeChildLimit) + throw std::runtime_error("settings child bound"); + return count; +} +Il2CppObject* Item(Il2CppObject* list, int index) +{ + void* args[] = {&index}; + return Call(list, "get_Item", 1, args); +} +bool HasLabel(Il2CppObject* row, const char* id) +{ + Root label(Call(row, "get_LabelContext")); + return label.get() && Equals(Call(label.get(), "get_Identifier"), id); +} + +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/interop.h b/mods/src/settings/native/interop.h new file mode 100644 index 000000000..240c12d9b --- /dev/null +++ b/mods/src/settings/native/interop.h @@ -0,0 +1,51 @@ +#pragma once + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include +#include +#include +#include + +namespace mod_settings::native +{ +struct Root { + Il2CppGCHandle handle = nullptr; + explicit Root(Il2CppObject* object, bool weak = false) + { + if (object) + handle = weak ? il2cpp_gchandle_new_weakref(object, false) : il2cpp_gchandle_new(object, false); + if (object && !handle) + throw std::runtime_error("settings root"); + } + ~Root() + { + if (handle) + il2cpp_gchandle_free(handle); + } + Root(const Root&) = delete; + Il2CppObject* get() const + { return handle ? il2cpp_gchandle_get_target(handle) : nullptr; } +}; + +// Shared native boundary helpers. No setting state, views or hook installation. +void Warn(const char* operation); +bool Type(const Il2CppType* type, int expected); +bool Instance(const MethodInfo* method, int count, int result); +void ValidateHookFit(const MethodInfo* method); +bool Reference(const Il2CppType* type); +FieldInfo* Field(Il2CppClass* cls, const char* name); +Il2CppObject* ReadField(Il2CppObject* object, FieldInfo* field); +Il2CppObject* Invoke(const MethodInfo* method, Il2CppObject* object, void** args = nullptr); +Il2CppObject* Call(Il2CppObject* object, const char* name, int count = 0, void** args = nullptr); +using Il2CppChecked::Boolean; +bool Equals(Il2CppObject* value, const char* ascii); +Il2CppObject* Target(Il2CppGCHandle handle); +void Free(Il2CppGCHandle& handle); +void SetActive(Il2CppObject* object, bool value); +Il2CppObject* MakeDelegate(Il2CppClass* cls, Il2CppObject* director, const MethodInfo* method); +int Count(Il2CppObject* list); +Il2CppObject* Item(Il2CppObject* list, int index); +bool HasLabel(Il2CppObject* row, const char* id); +} // namespace mod_settings::native + +#endif diff --git a/mods/src/settings/native/page_navigation.cc b/mods/src/settings/native/page_navigation.cc new file mode 100644 index 000000000..cf6815825 --- /dev/null +++ b/mods/src/settings/native/page_navigation.cc @@ -0,0 +1,750 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "page_navigation.h" +#include "action_widgets.h" +#include "patches/parts/fc_confirmation_reset.h" +#include "patches/runtime_config.h" +#include "row_style.h" +#include "settings/maintenance.h" +#include "settings/mod_pages.h" +#include "settings/native_boolean_callback.h" +#include "settings/page_sections.h" +#include "settings/text_entry.h" +#include "settings_search.h" +#include +#include +#include +#include +#include + +namespace mod_settings::native +{ +namespace +{ + constexpr auto saveNoticeId = "community_mod.save_notice"; + std::vector pagePlan; + std::vector categoryWidgets; + bool pagesActive = false; +} // namespace +const std::vector& Pages() +{ return pagePlan; } +bool PagesActive() +{ return pagesActive; } +void DisablePages() +{ pagesActive = false; } +PageMetadata& PageMeta() +{ + static PageMetadata metadata; + return metadata; +} +const PageCatalog::Page* PageFor(Il2CppObject* context) +{ + if (!context) + return nullptr; + for (const auto& page : Pages()) + if (HasLabel(context, page.id.c_str())) + return &page; + return nullptr; +} + +// One open page, with visit-local expansion state. The native context retains +// every child; only the list's presentation is filtered. Back and save ownership +// remain native, and a fresh page visit starts collapsed. +struct SectionPage { + const PageCatalog::Page* page = nullptr; + Il2CppGCHandle controller = nullptr, context = nullptr; + Il2CppGCHandle searchReturn = nullptr; // Only this visit came from a search result. + PageSections sections; + std::vector shown; // Comparison only; native context/panel owns rows. + bool conditional = false; +} sectionPage; +using SectionRefreshScope = PageSections::RefreshScope; +void ClearSectionPage() +{ + + CloseSettingsSearch(); + CloseTextEntry(); + const auto* leaving = std::exchange(sectionPage.page, nullptr); + Free(sectionPage.controller); + Free(sectionPage.context); + Free(sectionPage.searchReturn); + sectionPage.sections.ExpandAll(); + sectionPage.shown.clear(); + sectionPage.conditional = false; + try { + if (leaving && leaving->leave) + leaving->leave(); + } catch (...) { + Warn("settings page cleanup unavailable"); + } +} +const PageCatalog::Heading* CollapsibleHeadingFor(Il2CppObject* context) +{ + if (!context || context->klass != PageMeta().category.get_cls()) + return nullptr; + auto* callback = reinterpret_cast(ReadField(context, ToggleMeta().queryField)); + if (!callback || callback->method != QueryMethod() || callback->method_ptr != QueryMethod()->methodPointer) + return nullptr; + Root parent(Call(context, "get_Parent")); + if (const auto* page = PageFor(parent.get())) + for (const auto& item : page->items) + if (const auto* heading = std::get_if(&item); + heading && heading->collapsible && HasLabel(context, heading->id.c_str())) + return heading; + return nullptr; +} +bool Collapsed(const PageCatalog::Heading& heading) +{ return sectionPage.sections.Collapsed(heading); } +void ShowSections(Il2CppObject* controller, Il2CppObject* context, const PageCatalog::Page& page, bool force = false) +{ + + SyncActionRows(controller, context, page); + Root children(Call(context, "get_Children")); + struct OrderedRow { + Il2CppObject* row; + std::size_t position, index; + }; + std::vector ordered; + for (int i = 0, count = Count(children.get()); i < count; ++i) { + auto* row = Item(children.get(), i); // Rooted by the unchanged native children. + std::string id; + std::size_t index = 0; + if (OwnsValueContext(row)) { + if (const auto choice = ChoiceFor(row); choice.first) { + id = choice.first->state().id(); + index = choice.second; + } else if (auto* slider = SliderFor(row)) { + id = slider->state().id(); + } else if (auto* setting = SettingFor(row)) { + id = setting->id(); + } + } + if (ActionsActive()) + if (auto action = ActionFor(row); action.first) { + if (!action.first->Read(action.second).visible) + continue; + id = action.first->id(); + index = action.second; + } + if (id.empty()) + for (const auto& item : page.items) + if (const auto* heading = std::get_if(&item); + heading && HasLabel(row, heading->id.c_str())) { + id = heading->id; + break; + } + if (FilterSettingsSearch(page, id) && sectionPage.sections.Visible(page, id)) + ordered.push_back({row, page.PositionFor(id), index}); + } + // Repeated rows may have been appended after other native children. Keep the + // catalog's presentation order without rewriting the native ownership list. + std::stable_sort(ordered.begin(), ordered.end(), [](const auto& a, const auto& b) { + return std::tie(a.position, a.index) < std::tie(b.position, b.index); + }); + std::vector visible; + for (const auto& row : ordered) + visible.push_back(row.row); + if (!force && visible == sectionPage.shown) + return; + auto options = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "OptionContext"); + Root list(reinterpret_cast(il2cpp_array_new(options.get_cls(), visible.size()))); + if (!list.get()) + throw std::runtime_error("settings section list allocation"); + for (std::size_t i = 0; i < visible.size(); ++i) { + auto* array = reinterpret_cast(list.get()); + il2cpp_gc_wbarrier_set_field(list.get(), reinterpret_cast(&array->vector[i]), visible[i]); + } + Root panel(ReadField(controller, PageMeta().panel)); + static auto widgets = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "Widget"); + static auto panels = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "OptionTabPanelWidget"); + static const auto* schema = widgets.GetMethodInfo("BindDataContext", 2); + auto* lists = il2cpp_class_from_name(il2cpp_get_corlib(), "System.Collections", "IList"); + if (!panel.get() || panel.get()->klass != panels.get_cls() || !Instance(schema, 2, IL2CPP_TYPE_VOID) + || !(schema->flags & METHOD_ATTRIBUTE_VIRTUAL) || !Reference(schema->parameters[0]) + || !Type(schema->parameters[1], IL2CPP_TYPE_OBJECT) || !lists + || !il2cpp_class_is_assignable_from(lists, list.get()->klass)) + throw std::runtime_error("settings section list schema"); + // Resolve the non-generic Widget virtual slot, not the same-arity typed + // Widget overload. This is the object overload used by the game itself. + const auto* bind = il2cpp_object_get_virtual_method(panel.get(), schema); + if (!Instance(bind, 2, IL2CPP_TYPE_VOID) || bind->slot != schema->slot + || !Type(bind->parameters[1], IL2CPP_TYPE_OBJECT) + || il2cpp_class_from_type(bind->parameters[0]) != il2cpp_class_from_type(schema->parameters[0])) + throw std::runtime_error("settings section virtual binding"); + // The same provider/null + IList bind used by native OnCategorySelected. + // Native release/bind owns pooled widgets and their event subscriptions. + void* args[] = {nullptr, list.get()}; + Invoke(bind, panel.get(), args); + sectionPage.shown = std::move(visible); +} + +bool PageRefreshInProgress() +{ return sectionPage.sections.Refreshing(); } +void RenderCategory(Il2CppObject* widget) +{ + try { + Root context(Call(widget, "get_Context")); + if (auto* page = PageFor(context.get())) { + try { + Root background(RowImage(widget, "Background")); + RememberChoiceSprite(background.get()); + } catch (...) { + Warn("settings background unavailable"); + } + Root label(ReadField(widget, PageMeta().label)); + const auto summary = page->summary ? page->summary() : std::string{}; + SetRowText(widget, label.get(), page->label + (summary.empty() ? "" : " — " + summary)); + } else if (const auto* heading = CollapsibleHeadingFor(context.get())) { + Root label(ReadField(widget, PageMeta().label)); + const auto summary = Collapsed(*heading) && heading->summary ? heading->summary() : std::string{}; + SetRowText(widget, label.get(), + "" + heading->label + "" + + (summary.empty() ? "" : " — " + summary), + true, !Collapsed(*heading)); + } + } catch (...) { + Warn("settings category rendering unavailable"); + } +} +void ForgetCategory(Il2CppObject* widget) +{ + for (auto& handle : categoryWidgets) + if (!Target(handle) || Target(handle) == widget) + Free(handle); +} +void CategoryBindHook(auto original, Il2CppObject* widget) +{ + if (OnUIThread()) { + ForgetCategory(widget); + ClearRowText(widget); + } + original(widget); + if (!OnUIThread() || !pagesActive) + return; + try { + Root context(Call(widget, "get_Context")); + if (!PageFor(context.get()) && !CollapsibleHeadingFor(context.get())) + return; + auto slot = std::find(categoryWidgets.begin(), categoryWidgets.end(), nullptr); + if (slot == categoryWidgets.end()) { + categoryWidgets.push_back(nullptr); + slot = categoryWidgets.end() - 1; + } + *slot = il2cpp_gchandle_new_weakref(widget, false); + RenderCategory(widget); + } catch (...) { + Warn("settings category presentation unavailable"); + } +} +void RefreshPageSummaries() +{ + if (!OnUIThread() || !pagesActive) + return; + // Weak records are reused on bind/release. No scene search or idle polling. + // Keep indices across native text callbacks, which may rebind a pooled row. + for (std::size_t i = 0, count = categoryWidgets.size(); i < count; ++i) { + Root widget(Target(categoryWidgets[i])); + if (widget.get()) + RenderCategory(widget.get()); + else + Free(categoryWidgets[i]); + } +} +void CategoryReleaseHook(auto original, Il2CppObject* widget) +{ + if (OnUIThread()) { + ForgetCategory(widget); + ClearRowText(widget); + } + original(widget); +} +void PageSelectedHook(auto original, Il2CppObject* controller, Il2CppObject* context) +{ + // Keep a redirected context rooted through ClearSectionPage and native bind. + Root searchReturn(OnUIThread() ? Target(sectionPage.searchReturn) : nullptr); + if (OnUIThread() && pagesActive) { + bool sectionClick = false; + try { + if (const auto* heading = CollapsibleHeadingFor(context)) { + sectionClick = true; + if (sectionPage.sections.Refreshing() || Target(sectionPage.controller) != controller) + return; + Root parent(Call(context, "get_Parent")); + Root canvas(Call(controller, "get_CanvasContext")); + Root selected(Call(canvas.get(), "get_SelectedOption")); + if (!parent.get() || parent.get() != Target(sectionPage.context) || selected.get() != parent.get()) + return; // An old pooled heading cannot navigate or change this page. + SectionRefreshScope scope(sectionPage.sections); + const auto before = sectionPage.sections.Snapshot(); + sectionPage.sections.Toggle(*heading); + try { + ShowSections(controller, parent.get(), *PageFor(parent.get()), true); + } catch (...) { + sectionPage.sections.Restore(before); + try { + ShowSections(controller, parent.get(), *PageFor(parent.get()), true); + } catch (...) { + } + throw; + } + return; // A section click refreshes this page; it is not navigation. + } + } catch (const std::exception& error) { + // Invoke converts managed failures to fixed messages, without game data. + // Keep the concrete lookup/binding reason; a generic warning hid the + // incorrect SetContext lookup that prevented sections from folding. + Warn("settings section unavailable"); + if (sectionClick) + return; + } catch (...) { + Warn("settings section unavailable"); + if (sectionClick) + return; + } + try { + // Native Back passes SelectedOption.Parent to OnCategorySelected. Redirect + // only that transition for this controller's search-opened visit; section + // toggles above do not consume it. Do not reparent the native option tree. + if (searchReturn.get() && Target(sectionPage.controller) == controller) { + Root current(Target(sectionPage.context)); + Root canvas(Call(controller, "get_CanvasContext")); + Root selected(Call(canvas.get(), "get_SelectedOption")); + if (current.get() && selected.get() == current.get()) { + Root parent(Call(current.get(), "get_Parent")); + if (context == parent.get()) + context = searchReturn.get(); + } + } + } catch (...) { + Warn("search return unavailable; using normal Back destination"); + } + ClearSectionPage(); + try { + if (const auto* page = PageFor(context)) { + sectionPage.page = page; + sectionPage.controller = il2cpp_gchandle_new_weakref(controller, false); + sectionPage.context = il2cpp_gchandle_new_weakref(context, false); + if (!sectionPage.controller || !sectionPage.context) + ClearSectionPage(); + else { + sectionPage.conditional = page->HasConditionalSections(); + sectionPage.sections.Begin(*page); + } + } + } catch (...) { + ClearSectionPage(); + Warn("settings section owner unavailable"); + } + } + if (OnUIThread()) + ClearRowText(controller); + if (OnUIThread() && pagesActive) { + // Native navigation binds rows before returning. Callbacks from those rows + // must not rebind the panel while its initial population is still running. + SectionRefreshScope scope(sectionPage.sections); + original(controller, context); + } else { + original(controller, context); + } + if (!OnUIThread() || !pagesActive) + return; + try { + if (auto* page = PageFor(context)) { + Root label(ReadField(controller, PageMeta().title)); + SetRowText(controller, label.get(), page->label); + if (page->id == "community_mod.settings") + OpenSettingsSearch(controller); + if (Target(sectionPage.controller) == controller && Target(sectionPage.context) == context + && !sectionPage.sections.Refreshing()) { + // Let native navigation establish the page and Back target, then apply + // the initial folded presentation in the same call, before a frame draws. + SectionRefreshScope scope(sectionPage.sections); + try { + ShowSections(controller, context, *page, true); + } catch (...) { + // If folding is unavailable, keep the controls accessible and the + // heading arrows consistent with the expanded fallback. + sectionPage.sections.ExpandAll(); + try { + ShowSections(controller, context, *page); + } catch (...) { + } + throw; + } + } + } + } catch (...) { + Warn("selected settings page rendering unavailable"); + } +} +void PageDestroyedHook(auto original, Il2CppObject* controller) +{ + if (OnUIThread()) { + ClearRowText(controller); + if (Target(sectionPage.controller) == controller) + ClearSectionPage(); + } + original(controller); +} + +HeadingMetadata& HeadingMeta() +{ + static HeadingMetadata metadata; + return metadata; +} +NativeCallback headingGetter; +bool headingsActive = false; +Il2CppString* EmptyHeadingValue(Il2CppObject*, const MethodInfo*) +{ return il2cpp_string_new(""); } +const PageCatalog::Heading* HeadingFor(Il2CppObject* context) +{ + if (!context || context->klass != HeadingMeta().row.get_cls()) + return nullptr; + auto* callback = reinterpret_cast(ReadField(context, HeadingMeta().queryField)); + if (!callback || callback->method != QueryMethod() || callback->method_ptr != QueryMethod()->methodPointer) + return nullptr; + for (const auto& page : Pages()) + for (const auto& item : page.items) + if (auto* heading = std::get_if(&item); heading && HasLabel(context, heading->id.c_str())) + return heading; + return nullptr; +} +void HeadingRefreshHook(auto original, Il2CppObject* widget) +{ + if (OnUIThread()) + ClearRowText(widget); + original(widget); + if (!OnUIThread() || !headingsActive || !pagesActive) + return; + try { + Root context(Invoke(HeadingMeta().getContext, widget)); + if (const auto* heading = HeadingFor(context.get())) { + Root label(ReadField(widget, HeadingMeta().label)); + // Rich text stays inside the existing local override and is cleared with + // it. A darker bar and larger, bold label distinguish a heading from input. + SetRowText(widget, label.get(), "" + heading->label + "", true); + } + } catch (...) { + Warn("settings heading unavailable"); + } +} +void HeadingClearHook(auto original, Il2CppObject* widget) +{ + if (OnUIThread()) + ClearRowText(widget); + original(widget); +} + +void RefreshPageRows() +{ + if (!OnUIThread() || !pagesActive) + return; + if (sectionPage.sections.Refreshing()) + return; + // Value-change observers run inside the write guard. Rebinding there could + // release the requesting row before it has consumed its authoritative result. + if (ValueWidgetsBusy()) + return; + try { + Root controller(Target(sectionPage.controller)), context(Target(sectionPage.context)); + if (controller.get() && context.get()) { + Root canvas(Call(controller.get(), "get_CanvasContext")); + Root selected(Call(canvas.get(), "get_SelectedOption")); + if (const auto* page = PageFor(context.get()); page && selected.get() == context.get()) { + SectionRefreshScope scope(sectionPage.sections); + ShowSections(controller.get(), context.get(), *page); + } + } + } catch (...) { + Warn("settings page list refresh unavailable"); + } +} +void NavigateToSearchResult(std::string_view pageId, std::string_view itemId) +{ + Root controller(Target(sectionPage.controller)); + if (!controller.get() || !sectionPage.page || sectionPage.page->id != "community_mod.settings") + return; + Root returnToSearch(Target(sectionPage.context)); + if (!returnToSearch.get()) + return; + Root canvas(Call(controller.get(), "get_CanvasContext")); + Root root(Call(canvas.get(), "get_RootOption")); + // Follow catalog ancestry, bounding discovery to direct native children. + std::vector path; + for (std::string id(pageId); !id.empty();) { + const auto page = std::ranges::find(Pages(), id, &PageCatalog::Page::id); + if (page == Pages().end() || path.size() >= Pages().size()) + return; + path.push_back(page->id); + id = page->parent; + } + auto* target = root.get(); + for (auto it = path.rbegin(); it != path.rend(); ++it) { + Root children(Call(target, "get_Children")); + target = nullptr; + for (int i = 0, count = Count(children.get()); i < count; ++i) + if (HasLabel(Item(children.get(), i), it->c_str())) { + target = Item(children.get(), i); + break; + } + if (!target) + return; + } + Root selected(target); + void* args[]{selected.get()}; + Invoke(PageMeta().selected, controller.get(), args); + Root active(Call(canvas.get(), "get_SelectedOption")); + if (sectionPage.page && sectionPage.page->id == pageId && active.get() == selected.get() + && Target(sectionPage.controller) == controller.get()) { + sectionPage.searchReturn = il2cpp_gchandle_new_weakref(returnToSearch.get(), false); + if (!sectionPage.searchReturn) + Warn("search return unavailable; using normal Back destination"); + if (const auto* heading = sectionPage.page->SectionFor(itemId); heading && Collapsed(*heading)) + sectionPage.sections.Toggle(*heading); + RefreshActions(); + } +} +void RefreshConditionalSections() +{ + if (sectionPage.conditional) + RefreshPageRows(); +} +void AddHeadingRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, + const PageCatalog::Heading& heading) +{ + if (heading.collapsible) { + Root id(reinterpret_cast(il2cpp_string_new(heading.id.c_str()))); + Root state(MakeDelegate(il2cpp_class_from_type(PageMeta().add->parameters[3]), director, QueryMethod())); + void* args[] = {parent, id.get(), id.get(), state.get()}; + Root row(Invoke(PageMeta().add, context, args)); + if (!CollapsibleHeadingFor(row.get())) + throw std::runtime_error("settings section identity"); + return; + } + if (!headingsActive) + throw std::runtime_error("settings heading adapter missing"); + const auto* add = HeadingMeta().add; + Root children(Call(parent, "get_Children")); + const int before = Count(children.get()); + if (before == PageCatalog::NativeChildLimit) + throw std::runtime_error("settings heading capacity"); + Root label(reinterpret_cast(il2cpp_string_new(heading.id.c_str()))); + Root get(MakeDelegate(il2cpp_class_from_type(add->parameters[2]), director, headingGetter.method())); + Root state(MakeDelegate(il2cpp_class_from_type(add->parameters[3]), director, QueryMethod())); + void* args[] = {parent, label.get(), get.get(), state.get()}; + Invoke(add, context, args); + if (Count(children.get()) != before + 1) + throw std::runtime_error("settings heading insertion"); + Root row(Item(children.get(), before)); + if (!HeadingFor(row.get()) || !HasLabel(row.get(), heading.id.c_str())) + throw std::runtime_error("settings heading identity"); +} + +void AddPages(Il2CppObject* director, Il2CppObject* context) +{ + if (!pagesActive || Pages().empty()) + return; + + Root root(Call(context, "get_RootOption")); + Root children(Call(root.get(), "get_Children")); + for (int i = 0, count = Count(children.get()); i < count; ++i) + if (HasLabel(Item(children.get(), i), Pages().front().id.c_str())) + return; + std::map parents; + Il2CppObject* addedRoot = nullptr; + std::optional addedRootGuard; + try { + for (const auto& page : Pages()) { + auto* parent = page.parent.empty() ? root.get() : parents.at(page.parent); + Root id(reinterpret_cast(il2cpp_string_new(page.id.c_str()))); + Root state(MakeDelegate(il2cpp_class_from_type(PageMeta().add->parameters[3]), director, QueryMethod())); + void* args[] = {parent, id.get(), id.get(), state.get()}; + Root category(Invoke(PageMeta().add, context, args)); + if (!category.get()) + throw std::runtime_error("settings category construction"); + if (!addedRoot) { + addedRoot = category.get(); + addedRootGuard.emplace(addedRoot); + } + if (!HasLabel(category.get(), page.id.c_str())) + throw std::runtime_error("settings category identity"); + parents.emplace(page.id, category.get()); // Native root owns all added contexts. + for (const auto& item : page.items) + std::visit( + [&](const auto& value) { + using T = std::decay_t; + if constexpr (std::is_same_v) + AddHeadingRow(director, context, category.get(), value); + else if constexpr (std::is_same_v) + AddBooleanRow(director, context, category.get(), *value); + else if constexpr (std::is_same_v) + AddChoiceRows(director, context, category.get(), *value); + else if constexpr (std::is_same_v) { + for (std::size_t index = 0, count = value->Count(); index < count; ++index) + AddActionRow(director, context, category.get(), *value, index); + } else + AddSliderRow(director, context, category.get(), *value); + }, + item); + } + // Unsupported leaf adapters can leave empty groups; remove them bottom-up. + for (auto it = Pages().rbegin(); it != Pages().rend(); ++it) { + auto* category = parents.at(it->id); + Root items(Call(category, "get_Children")); + const auto count = Count(items.get()); + const auto action = count == 1 && ActionsActive() ? ActionFor(Item(items.get(), 0)).first : nullptr; + // A failure-only notice is not supported content. If a widget family was + // unavailable, prune notice-only leaves and then their notice-only parents. + if (count != 0 && !(action && action->id() == saveNoticeId)) + continue; + auto* parent = it->parent.empty() ? root.get() : parents.at(it->parent); + void* args[] = {category}; + Call(parent, "RemoveChild", 1, args); + } + } catch (...) { + if (addedRoot) { + void* args[] = {addedRoot}; + Call(root.get(), "RemoveChild", 1, args); + } + throw; + } +} + +void RefreshSavePresentations() +{ + RefreshViews(); + RefreshActions(); +} + +void InstallPages() +{ + RegisterSettingsSearch(ModPages()); + RegisterModPages(); + RegisterMaintenancePages(ModPages()); + pagePlan = ModPages().Build(); + // Add after empty-page pruning so a notice never creates an otherwise empty group. + static ActionSetting saveNotice{saveNoticeId, "Save notice", + [](std::size_t) { + return ActionSetting::Presentation{ + "Some changes weren't saved. See Defaults & Help.", "", + "", false, runtime_config::HasSaveFailures()}; + }, + [](std::size_t) {}}; + for (auto& page : pagePlan) + page.items.insert(page.items.begin(), &saveNotice); + std::size_t rowCount = 2; // FC and FT live on the native confirmation page. + for (const auto& page : Pages()) + rowCount += page.ControlRows() - std::ranges::distance(page.Controls()); + PrepareValueViews(rowCount); + if (Pages().empty()) + return; + auto& m = PageMeta(); + const std::array hooks{m.bind, m.release, m.selected, m.destroyed}; + const std::array hookNames{"CategoryOptionWidget.OnDidBindContext", "CategoryOptionWidget.OnAboutToReleaseContext", + "GameSettingsViewController.OnCategorySelected", "GameSettingsViewController.OnDestroy"}; + for (std::size_t i = 0; i < hooks.size(); ++i) { + if (!Instance(hooks[i], i == 2 ? 1 : 0, IL2CPP_TYPE_VOID)) + throw std::runtime_error(std::string(hookNames[i]) + ": incompatible instance Void hook signature"); + for (std::size_t j = 0; j < i; ++j) + if (hooks[i]->methodPointer == hooks[j]->methodPointer) + throw std::runtime_error(std::string(hookNames[i]) + " shares target with " + hookNames[j]); + const auto& core = ToggleMeta(); + for (auto* owned : + {core.addGeneral, core.refresh, core.changed, core.release, core.reload, core.session, core.load}) + if (hooks[i]->methodPointer == owned->methodPointer) + throw std::runtime_error(std::string(hookNames[i]) + " shares target with " + owned->klass->name + "." + + owned->name); + } + if (!Instance(m.add, 4, IL2CPP_TYPE_CLASS) || !Reference(m.add->parameters[0]) + || !Type(m.add->parameters[1], IL2CPP_TYPE_STRING) || !Type(m.add->parameters[2], IL2CPP_TYPE_STRING) + || !Reference(m.add->parameters[3])) + throw std::runtime_error( + "SettingsContext.AddCategory: expected instance reference result and (reference, String, String, reference)"); + if (!Reference(m.selected->parameters[0])) + throw std::runtime_error("GameSettingsViewController.OnCategorySelected: expected reference parameter"); + InstallChoiceAndSliderWidgets(); + if (std::any_of(Pages().begin(), Pages().end(), [](const auto& page) { + return std::any_of(page.items.begin(), page.items.end(), [](const auto& item) { + const auto* heading = std::get_if(&item); + return heading && !heading->collapsible; + }); + })) { + auto& heading = HeadingMeta(); + const auto* get = ToggleMeta().director.GetMethodInfo("GetClientVersion", 0); + if (!Instance(get, 0, IL2CPP_TYPE_STRING)) + throw std::runtime_error("SettingsSectionDirector.GetClientVersion: expected instance String()"); + if (!Instance(heading.add, 4, IL2CPP_TYPE_VOID) || !Reference(heading.add->parameters[0]) + || !Type(heading.add->parameters[1], IL2CPP_TYPE_STRING) || !Reference(heading.add->parameters[2]) + || !Reference(heading.add->parameters[3])) + throw std::runtime_error( + "SettingsContext.AddText: expected instance Void(reference, String, reference, reference)"); + if (!heading.getContext || !Reference(heading.getContext->return_type) + || !Instance(heading.getContext, 0, heading.getContext->return_type->type)) + throw std::runtime_error("TextOptionWidget.get_Context: expected instance reference result"); + if (!headingGetter.Initialize(get, EmptyHeadingValue)) + throw std::runtime_error("heading getter: incompatible native callback schema"); + const std::array targets{heading.refresh, heading.clear}; + const std::array names{"TextOptionWidget.SetWidgetData", "TextOptionWidget.ClearWidgetData"}; + for (std::size_t i = 0; i < targets.size(); ++i) + if (!Instance(targets[i], 0, IL2CPP_TYPE_VOID)) + throw std::runtime_error(std::string(names[i]) + ": expected instance Void()"); + if (targets[0]->methodPointer == targets[1]->methodPointer) + throw std::runtime_error("TextOptionWidget.SetWidgetData and ClearWidgetData share a native hook target"); + for (auto* target : targets) { + const auto& core = ToggleMeta(); + for (auto* existing : {core.refresh, core.changed, core.release, core.addGeneral, core.reload, core.session, + core.load, m.bind, m.release, m.selected, m.destroyed}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string("TextOptionWidget.") + target->name + " shares target with " + + existing->klass->name + "." + existing->name); + if (SelectionActive()) + for (auto* existing : {SelectionMeta().refresh, SelectionMeta().changed, SelectionMeta().release}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string("TextOptionWidget.") + target->name + " shares target with " + + existing->klass->name + "." + existing->name); + if (SliderActive()) + for (auto* existing : + {SliderMeta().refresh, SliderMeta().changed, SliderMeta().release, SliderMeta().valueLabel}) + if (target->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string("TextOptionWidget.") + target->name + " shares target with " + + existing->klass->name + "." + existing->name); + } + ValidateHookFit(heading.refresh); + if (!SPUD_STATIC_DETOUR(heading.refresh->methodPointer, HeadingRefreshHook)) + throw std::runtime_error("TextOptionWidget.SetWidgetData: hook installation failed"); + ValidateHookFit(heading.clear); + if (!SPUD_STATIC_DETOUR(heading.clear->methodPointer, HeadingClearHook)) + throw std::runtime_error("TextOptionWidget.ClearWidgetData: hook installation failed"); + headingsActive = true; + } + InstallActionWidgets(); + InstallSettingsSearch(); + if (ActionsActive() && !runtime_config::SetSaveStatusObserver(RefreshSavePresentations)) + Warn("settings save notice refresh unavailable"); + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) { + if (setting->id() == FleetCommanderConfirmationSetting().id() && setting != &FleetCommanderConfirmationSetting()) + throw std::runtime_error("settings owner collision"); + if (!setting->SetChangeObserver(RefreshViews)) + throw std::runtime_error("settings observer ownership"); + } + ValidateHookFit(m.bind); + if (!SPUD_STATIC_DETOUR(m.bind->methodPointer, CategoryBindHook)) + throw std::runtime_error("CategoryOptionWidget.OnDidBindContext: hook installation failed"); + ValidateHookFit(m.release); + if (!SPUD_STATIC_DETOUR(m.release->methodPointer, CategoryReleaseHook)) + throw std::runtime_error("CategoryOptionWidget.OnAboutToReleaseContext: hook installation failed"); + ValidateHookFit(m.selected); + if (!SPUD_STATIC_DETOUR(m.selected->methodPointer, PageSelectedHook)) + throw std::runtime_error("GameSettingsViewController.OnCategorySelected: hook installation failed"); + ValidateHookFit(m.destroyed); + if (!SPUD_STATIC_DETOUR(m.destroyed->methodPointer, PageDestroyedHook)) + throw std::runtime_error("GameSettingsViewController.OnDestroy: hook installation failed"); + pagesActive = true; + spdlog::info("[ModSettings] Native navigation installed: {} registered pages", Pages().size()); +} + +bool HeadingsActive() +{ return headingsActive; } + +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/page_navigation.h b/mods/src/settings/native/page_navigation.h new file mode 100644 index 000000000..494239ad5 --- /dev/null +++ b/mods/src/settings/native/page_navigation.h @@ -0,0 +1,53 @@ +#pragma once + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "value_widgets.h" + +namespace mod_settings::native +{ +struct PageMetadata { + IL2CppClassHelper category = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "CategoryOptionContext"); + IL2CppClassHelper categoryWidget = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "CategoryOptionWidget"); + IL2CppClassHelper controller = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "GameSettingsViewController"); + const MethodInfo* add = ToggleMeta().context.GetMethodInfo("AddCategory", 4); + const MethodInfo* bind = categoryWidget.GetMethodInfo("OnDidBindContext", 0); + const MethodInfo* release = categoryWidget.GetMethodInfo("OnAboutToReleaseContext", 0); + const MethodInfo* selected = controller.GetMethodInfo("OnCategorySelected", 1); + const MethodInfo* destroyed = controller.GetMethodInfo("OnDestroy", 0); + FieldInfo* label = Field(categoryWidget.get_cls(), "_label"); + FieldInfo* title = Field(controller.get_cls(), "_title"); + FieldInfo* panel = Field(controller.get_cls(), "_optionTabPanel"); +}; +struct HeadingMetadata { + IL2CppClassHelper widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "TextOptionWidget"); + IL2CppClassHelper row = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "TextOptionContext"); + const MethodInfo* refresh = widget.GetMethodInfo("SetWidgetData", 0); + const MethodInfo* clear = widget.GetMethodInfo("ClearWidgetData", 0); + const MethodInfo* add = ToggleMeta().context.GetMethodInfo("AddText", 4); + const MethodInfo* getContext = widget.GetMethodInfo("get_Context", 0); + FieldInfo* label = Field(widget.get_cls(), "_label"); + FieldInfo* queryField = Field(row.get_cls(), "k__BackingField"); +}; + +// Metadata access supports startup signature/overlap checks across hook owners. +PageMetadata& PageMeta(); +HeadingMetadata& HeadingMeta(); +bool HeadingsActive(); +const std::vector& Pages(); +bool PagesActive(); +void DisablePages(); +const PageCatalog::Page* PageFor(Il2CppObject* context); +bool PageRefreshInProgress(); +void ClearSectionPage(); +void RefreshPageRows(); +void RefreshPageSummaries(); +void RefreshConditionalSections(); +void AddPages(Il2CppObject* director, Il2CppObject* context); +void InstallPages(); +Il2CppString* EmptyHeadingValue(Il2CppObject*, const MethodInfo*); +} // namespace mod_settings::native + +#endif diff --git a/mods/src/settings/native/row_style.cc b/mods/src/settings/native/row_style.cc new file mode 100644 index 000000000..e88959685 --- /dev/null +++ b/mods/src/settings/native/row_style.cc @@ -0,0 +1,266 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "row_style.h" +#include "prime/Vector3.h" +#include "value_widget_record.h" +#include + +namespace mod_settings::native +{ +void RestoreTint(RowTint& tint) +{ + try { + if (auto* image = Target(tint.image)) { + void* args[] = {&tint.before}; + Call(image, "set_color", 1, args); + } + } catch (...) { + Warn("settings row tint restoration unavailable"); + } + Free(tint.image); +} +// The build261 settings prefabs put backgrounds on a direct BG child (category +// rows use Background). Do not search arbitrary descendants such as a checkbox. +Il2CppObject* RowImage(Il2CppObject* widget, const char* child) +{ + static auto images = il2cpp_get_class_helper("UnityEngine.UI", "UnityEngine.UI", "Image"); + static auto objects = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "GameObject"); + static auto get = objects.GetMethodInfoSpecial("GetComponent", [](auto count, auto params) { + return count == 1 && Type(params[0], IL2CPP_TYPE_CLASS) + && std::strcmp(il2cpp_class_get_name(il2cpp_class_from_type(params[0])), "Type") == 0; + }); + Root transform(Call(widget, "get_transform")); + Root name(reinterpret_cast(il2cpp_string_new(child))); + void* findArgs[] = {name.get()}; + Root background(Call(transform.get(), "Find", 1, findArgs)); + if (!background.get()) + return nullptr; + Root object(Call(background.get(), "get_gameObject")); + void* args[] = {images.GetType()}; + return Invoke(get, object.get(), args); +} +void TintImage(RowTint& tint, Il2CppObject* image, color value, bool multiply) +{ + RestoreTint(tint); + if (!image) + return; + static auto colors = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Color"); + Root boxed(Call(image, "get_color")); + const auto* set = il2cpp_class_get_method_from_name(image->klass, "set_color", 1); + if (!boxed.get() || boxed.get()->klass != colors.get_cls() || !Instance(set, 1, IL2CPP_TYPE_VOID) + || set->parameters[0]->byref || il2cpp_class_from_type(set->parameters[0]) != colors.get_cls()) + return; + tint.before = *static_cast(il2cpp_object_unbox(boxed.get())); + tint.image = il2cpp_gchandle_new_weakref(image, false); + if (!tint.image) + return; + if (multiply) { + value.r *= tint.before.r; + value.g *= tint.before.g; + value.b *= tint.before.b; + } + value.a = tint.before.a; + void* args[] = {&value}; + Invoke(set, image, args); +} +void TintRow(RowTint& tint, Il2CppObject* widget, color multiplier) +{ + try { + Root image(RowImage(widget)); + TintImage(tint, image.get(), multiplier); + } catch (...) { + RestoreTint(tint); + Warn("settings row tint unavailable"); + } +} +// Reuse sprites already rendered by these native settings widgets. Weak handles +// do not keep a scene/bundle alive. No asset loading, animation sampling or polling. +Il2CppGCHandle normalChoiceSprite = nullptr, pressedChoiceSprite = nullptr; +void RememberChoiceSprite(Il2CppObject* background) +{ + if (!background || (Target(normalChoiceSprite) && Target(pressedChoiceSprite))) + return; + Root sprite(Call(background, "get_sprite")); + if (!sprite.get()) + return; + Root name(Call(sprite.get(), "get_name")); + auto& handle = Equals(name.get(), "SelectedBG_raw") ? pressedChoiceSprite : normalChoiceSprite; + if (!Target(handle)) { + Free(handle); + handle = il2cpp_gchandle_new_weakref(sprite.get(), false); + } +} + +bool HasPressedChoiceSprite() +{ return Target(pressedChoiceSprite) != nullptr; } +void ClearChoiceStyle(ValueWidget& view) +{ + RestoreTint(view.checkTint); + try { + if (view.choiceStyled) { + if (auto* background = Target(view.choiceBackground)) { + Root before(Target(view.choiceOverrideBefore)); + void* args[] = {before.get()}; + Call(background, "set_overrideSprite", 1, args); + } + } + } catch (...) { + Warn("settings selection style restoration unavailable"); + } + Free(view.choiceBackground); + Free(view.choiceOverrideBefore); + Free(view.choiceCheck); + view.choiceStyled = view.pressed = false; +} +void StyleChoice(ValueWidget& view) +{ + auto* widget = Target(view.widget); + if (!widget || !view.state || !WidgetMeta(widget).selection) + return; + if (!view.choiceStyled) { + Root background(RowImage(widget)); + Root check(RowImage(widget, "Arrow")); + if (!background.get() || !check.get()) + return; + RememberChoiceSprite(background.get()); + if (!Target(normalChoiceSprite)) + return; + Root before(ReadField(background.get(), Field(background.get()->klass, "m_OverrideSprite"))); + view.choiceBackground = il2cpp_gchandle_new_weakref(background.get(), false); + view.choiceCheck = il2cpp_gchandle_new_weakref(check.get(), false); + view.choiceOverrideBefore = before.get() ? il2cpp_gchandle_new_weakref(before.get(), false) : nullptr; + if (!view.choiceBackground || !view.choiceCheck || (before.get() && !view.choiceOverrideBefore)) + throw std::runtime_error("settings selection style roots"); + view.choiceStyled = true; + } + Root background(Target(view.choiceBackground)); + // Selection animation still updates sprite, geometry and the actual checkmark. + // Image.overrideSprite changes only the drawn background, so the native isOn + // animation can keep running. Pointer events provide transient pressed feedback. + RememberChoiceSprite(background.get()); + Root sprite(Target(view.pressed ? pressedChoiceSprite : normalChoiceSprite)); + if (!sprite.get()) + return; + void* args[] = {sprite.get()}; + Call(background.get(), "set_overrideSprite", 1, args); + Root check(Target(view.choiceCheck)); + TintImage(view.checkTint, check.get(), view.pressed ? color{0.22f, 0.22f, 0.22f, 1} : color{0.88f, 0.95f, 0.97f, 1}, + false); + std::string text = view.state->label(); + if (!view.state->known()) + text += " — " + std::string(view.state->unavailableReason()); + else if (view.state->failed()) + text += " — Retry"; + if (view.state->value().value_or(false)) + text = "" + text + ""; + text = std::string(view.pressed ? "" : "") + text + ""; + Root label(Target(view.label)); + Root message(reinterpret_cast(il2cpp_string_new(text.c_str()))); + void* textArgs[] = {message.get()}; + Call(label.get(), "OverrideLocalizedText", 1, textArgs); + view.overridden = true; +} + +void TryStyleChoice(ValueWidget& view) +{ + try { + StyleChoice(view); + } catch (...) { + ClearChoiceStyle(view); + Warn("settings selection presentation unavailable"); + } +} + +struct RowTextOverride { + Il2CppGCHandle owner = nullptr, label = nullptr; + RowTint tint; + Il2CppGCHandle arrow = nullptr; + Vector3 arrowBefore{}; +}; +std::vector textOverrides; +void ClearRowText(Il2CppObject* owner) +{ + for (auto it = textOverrides.begin(); it != textOverrides.end();) { + auto* live = Target(it->owner); + if (live && live != owner) { + ++it; + continue; + } + RestoreTint(it->tint); + try { + if (auto* arrow = Target(it->arrow)) { + void* args[] = {&it->arrowBefore}; + Call(arrow, "set_localEulerAngles", 1, args); + } + } catch (...) { + Warn("settings arrow rotation restoration unavailable"); + } + try { + if (auto* label = Target(it->label)) + Call(label, "ClearTextOverride"); + } catch (...) { + Warn("settings label restoration unavailable"); + } + Free(it->owner); + Free(it->label); + Free(it->arrow); + it = textOverrides.erase(it); + } +} +void SetRowText(Il2CppObject* owner, Il2CppObject* label, const std::string& text, bool heading, + std::optional expanded) +{ + if (!owner || !label) + throw std::runtime_error("settings text missing"); + ClearRowText(owner); + RowTextOverride record{il2cpp_gchandle_new_weakref(owner, false), il2cpp_gchandle_new_weakref(label, false)}; + try { + if (!record.owner || !record.label) + throw std::runtime_error("settings text weak root"); + if (heading) { + Root background(RowImage(owner, expanded ? "Background" : "BG")); + TintImage(record.tint, background.get(), {0.35f, 0.50f, 0.56f, 1.0f}); + } + if (expanded) { + Root image(RowImage(owner, "Arrow")); + if (image.get()) { + Root arrow(Call(image.get(), "get_transform")); + Root rotation(Call(arrow.get(), "get_localEulerAngles")); + auto vectors = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Vector3"); + const auto* set = il2cpp_class_get_method_from_name(arrow.get()->klass, "set_localEulerAngles", 1); + if (rotation.get() && rotation.get()->klass == vectors.get_cls() && Instance(set, 1, IL2CPP_TYPE_VOID) + && !set->parameters[0]->byref && il2cpp_class_from_type(set->parameters[0]) == vectors.get_cls()) { + record.arrowBefore = *static_cast(il2cpp_object_unbox(rotation.get())); + record.arrow = il2cpp_gchandle_new_weakref(arrow.get(), false); + if (record.arrow) { + auto value = record.arrowBefore; + if (*expanded) + value.z -= 90.0f; + void* args[] = {&value}; + Invoke(set, arrow.get(), args); + } + } + } + } + textOverrides.push_back(record); + } catch (...) { + RestoreTint(record.tint); + if (auto* arrow = Target(record.arrow)) { + try { + void* args[] = {&record.arrowBefore}; + Call(arrow, "set_localEulerAngles", 1, args); + } catch (...) { + Warn("settings arrow rollback unavailable"); + } + } + Free(record.arrow); + Free(record.owner); + Free(record.label); + throw; + } + Root message(reinterpret_cast(il2cpp_string_new(text.c_str()))); + void* args[] = {message.get()}; + Call(label, "OverrideLocalizedText", 1, args); +} + +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/row_style.h b/mods/src/settings/native/row_style.h new file mode 100644 index 000000000..cb199ed77 --- /dev/null +++ b/mods/src/settings/native/row_style.h @@ -0,0 +1,31 @@ +#pragma once + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "interop.h" +#include "prime/Color.h" +#include +#include + +namespace mod_settings::native +{ +struct ValueWidget; +struct RowTint { + Il2CppGCHandle image = nullptr; + color before{}; +}; + +// Scoped overrides only. Native release/rebind restores the previous appearance. +void RestoreTint(RowTint& tint); +Il2CppObject* RowImage(Il2CppObject* widget, const char* child = "BG"); +void TintImage(RowTint& tint, Il2CppObject* image, color value, bool multiply = true); +void TintRow(RowTint& tint, Il2CppObject* widget, color multiplier); +void RememberChoiceSprite(Il2CppObject* background); +bool HasPressedChoiceSprite(); +void ClearChoiceStyle(ValueWidget& view); +void TryStyleChoice(ValueWidget& view); +void ClearRowText(Il2CppObject* owner); +void SetRowText(Il2CppObject* owner, Il2CppObject* label, const std::string& text, bool heading = false, + std::optional expanded = {}); +} // namespace mod_settings::native + +#endif diff --git a/mods/src/settings/native/settings_search.cc b/mods/src/settings/native/settings_search.cc new file mode 100644 index 000000000..755d3aa7e --- /dev/null +++ b/mods/src/settings/native/settings_search.cc @@ -0,0 +1,266 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "settings_search.h" +#include "action_widgets.h" +#include "page_navigation.h" +#include "patches/key.h" +#include "patches/screen_update_hook.h" +#include "settings/settings_search.h" +#include "str_utils.h" +#include "ui_helpers.h" +#include + +namespace mod_settings::native +{ +namespace +{ + using namespace ui; + constexpr auto rootId = "community_mod.settings"; + constexpr auto resultsId = "community_mod.search.results"; + struct Vec2 { + float x, y; + }; + struct Color { + float r, g, b, a; + }; + SettingsSearch index; + std::vector matches; + std::string query; + std::optional pending; + Il2CppGCHandle object{}, input{}, owner{}, panel{}, back{}; + Vec2 panelOffset{}; + bool installed = false, movedPanel = false, backHeld = false, backEnabled = false; + int releaseFrames = 0; + + template T ReadValue(Il2CppObject* component, const char* name, const char* type) + { + Root boxed(UiCall(component, name)); + if (!boxed.get() || boxed.get()->klass != UnityClass(type)) + throw std::runtime_error("settings search layout value"); + return *static_cast(il2cpp_object_unbox(boxed.get())); + } + void ReleaseBack() + { + if (backHeld && Alive(Target(back))) + Value(Target(back), "set_enabled", backEnabled); + backHeld = false; + releaseFrames = 0; + Key::settingsSearchActive = false; + } + void SetQuery(std::string text) + { + query = std::move(text); + matches = index.Find(query); + } + Il2CppObject* NewObject(const char* name, Il2CppObject* parent) + { + Root result(il2cpp_object_new(UnityClass("GameObject"))); + Root label(reinterpret_cast(il2cpp_string_new(name))); + const auto* ctor = IL2CppClassHelper(UnityClass("GameObject")).GetMethodInfoSpecial(".ctor", [](auto n, auto p) { + return n == 1 && Type(p[0], IL2CPP_TYPE_STRING); + }); + void* args[] = {label.get()}; + Invoke(ctor, result.get(), args); + if (!parent) + Retain(object, result.get()); + Root transform(UiCall(result.get(), "get_transform")); + if (parent) { + bool world = false; + void* parentArgs[]{parent, &world}; + UiCall(transform.get(), "SetParent", 2, parentArgs); + } + WithType(result.get(), "AddComponent", UnityClass("RectTransform")); + Value(result.get(), "set_layer", 5); + return result.get(); + } + void Stretch(Il2CppObject* transform, Vec2 min, Vec2 max) + { + Value(transform, "set_anchorMin", Vec2{0, 0}); + Value(transform, "set_anchorMax", Vec2{1, 1}); + Value(transform, "set_offsetMin", min); + Value(transform, "set_offsetMax", max); + } + Il2CppObject* Label(Il2CppObject* parent, Il2CppObject* font, const char* name, const char* text, Color color) + { + Root child(NewObject(name, parent)); + Root transform(UiCall(child.get(), "get_transform")); + Stretch(transform.get(), {0, 0}, {0, 0}); + Root label(WithType(child.get(), "AddComponent", Class("Unity.TextMeshPro", "TMPro", "TextMeshProUGUI"))); + Set(label.get(), "set_font", font); + Value(label.get(), "set_fontSize", 26.f); + Value(label.get(), "set_alignment", 0x201); // Middle left. + Value(label.get(), "set_color", color); + Value(label.get(), "set_raycastTarget", false); + Value(label.get(), "set_richText", false); + Value(label.get(), "set_enableWordWrapping", false); + Text(label.get(), text); + return label.get(); + } + void Tick() + { + if (!object && !backHeld) + return; + try { + if (!Alive(Target(object)) || !Alive(Target(owner)) + || !Boolean(UiCall(Target(object), "get_activeInHierarchy"))) { + CloseSettingsSearch(); + return; + } + const bool editing = Boolean(UiCall(Target(input), "get_isFocused")); + if (editing) { + if (!backHeld) { + backEnabled = Boolean(UiCall(Target(back), "get_enabled")); + backHeld = true; + Value(Target(back), "set_enabled", false); + } + Key::settingsSearchActive = true; + releaseFrames = 2; + } else if (backHeld && !Key::RawPressed(KeyCode::Escape) && !Key::RawPressed(KeyCode::Return)) { + if (--releaseFrames <= 0) + ReleaseBack(); + } + Root text(UiCall(Target(input), "get_text")); + if (!text.get() || !Type(il2cpp_class_get_type(text.get()->klass), IL2CPP_TYPE_STRING)) + throw std::runtime_error("settings search text"); + auto next = to_string(reinterpret_cast(text.get())); + if (next != query) { + SetQuery(std::move(next)); + RefreshActions(); + } + // A result click must finish before native navigation releases that row. + if (pending) { + const auto target = std::exchange(pending, {}); + NavigateToSearchResult(target->page, target->item); + } + } catch (const std::exception& error) { + spdlog::warn("[ModSettings] Search closed: {}", error.what()); + CloseSettingsSearch(); + SetQuery({}); + RefreshActions(); + } + } +} // namespace + +void RegisterSettingsSearch(PageCatalog& catalog) +{ + static ActionSetting results{ + resultsId, "Search results", + [](std::size_t row) { + if (row == 0) { + const auto label = matches.empty() ? std::string("No matching settings") + : std::to_string(matches.size()) + " results" + + (matches.size() > SettingsSearch::ResultLimit + ? " (showing first 32; keep typing to narrow)" + : ""); + return ActionSetting::Presentation{label, "Clear", "", true}; + } + const auto& hit = *matches.at(row - 1); + const auto page = std::ranges::find(Pages(), hit.page, &PageCatalog::Page::id); + const bool hidden = page != Pages().end() && !hit.item.empty() && !page->IsVisible(hit.item); + return ActionSetting::Presentation{hit.label + "\n" + hit.location + + (hidden ? " (hidden by another setting)" : "") + "", + "Open", hit.key, true}; + }, + [](std::size_t row) { + if (row == 0) { + // Tick owns refresh; do not release a clicked pooled row in its callback. + if (Alive(Target(input))) + Text(Target(input), ""); + } else if (row <= matches.size()) { + pending = *matches[row - 1]; + } + }, + [] { return query.empty() ? 0u : 1 + std::min(matches.size(), SettingsSearch::ResultLimit); }}; + catalog.AddAction(rootId, results); +} +bool FilterSettingsSearch(const PageCatalog::Page& page, std::string_view id) +{ return page.id != rootId || query.empty() || id == resultsId || id == "community_mod.save_notice"; } +void CloseSettingsSearch() +{ + pending.reset(); + try { + ReleaseBack(); + if (movedPanel && Alive(Target(panel))) + Value(Target(panel), "set_offsetMax", panelOffset); + if (Alive(Target(object))) { + SetActive(Target(object), false); + void* args[]{Target(object)}; + Static(IL2CppClassHelper(UnityClass("Object")).GetMethodInfo("Destroy", 1), args); + } + } catch (...) { + Warn("search cleanup unavailable"); + } + movedPanel = false; + backHeld = false; + Key::settingsSearchActive = false; + for (auto* handle : {&object, &input, &owner, &panel, &back}) + Free(*handle); +} +void OpenSettingsSearch(Il2CppObject* controller) +{ + if (!installed || !ActionsActive()) + return; + try { + Retain(owner, controller); + Retain(back, ReadField(controller, Field(controller->klass, "_backButtonViewController"))); + Root nativePanel(ReadField(controller, PageMeta().panel)); + Root rect(UiCall(nativePanel.get(), "get_transform")); + Retain(panel, rect.get()); + Root parent(UiCall(rect.get(), "get_parent")); + const auto min = ReadValue(rect.get(), "get_anchorMin", "Vector2"); + const auto max = ReadValue(rect.get(), "get_anchorMax", "Vector2"); + const auto offsetMin = ReadValue(rect.get(), "get_offsetMin", "Vector2"); + panelOffset = ReadValue(rect.get(), "get_offsetMax", "Vector2"); + Root title(ReadField(controller, PageMeta().title)); + Root titleObject(UiCall(title.get(), "get_gameObject")); + Root source(WithType(titleObject.get(), "GetComponent", Class("Unity.TextMeshPro", "TMPro", "TMP_Text"))); + Root font(UiCall(source.get(), "get_font")); + if (!font.get()) + throw std::runtime_error("settings search font"); + Root root(NewObject("CommunityMod_SettingsSearch", nullptr)); + SetActive(root.get(), false); + Root transform(UiCall(root.get(), "get_transform")); + bool world = false; + void* parentArgs[]{parent.get(), &world}; + UiCall(transform.get(), "SetParent", 2, parentArgs); + Value(transform.get(), "set_anchorMin", Vec2{min.x, max.y}); + Value(transform.get(), "set_anchorMax", Vec2{max.x, max.y}); + Value(transform.get(), "set_offsetMin", Vec2{offsetMin.x, panelOffset.y - 76}); + Value(transform.get(), "set_offsetMax", Vec2{panelOffset.x, panelOffset.y - 4}); + Root image(WithType(root.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "Image"))); + Value(image.get(), "set_color", Color{.10f, .20f, .25f, 1}); + Root viewport(NewObject("Text Area", transform.get())); + Root viewportRect(UiCall(viewport.get(), "get_transform")); + Stretch(viewportRect.get(), {20, 6}, {-20, -6}); + WithType(viewport.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "RectMask2D")); + Root text(Label(viewportRect.get(), font.get(), "Text", "", {.88f, .95f, .97f, 1})); + Root placeholder(Label(viewportRect.get(), font.get(), "Placeholder", "Search settings, shortcuts or TOML name...", + {.60f, .72f, .76f, 1})); + Root field(WithType(root.get(), "AddComponent", Class("Unity.TextMeshPro", "TMPro", "TMP_InputField"))); + Retain(input, field.get()); + Set(field.get(), "set_targetGraphic", image.get()); + Set(field.get(), "set_textViewport", viewportRect.get()); + Set(field.get(), "set_textComponent", text.get()); + Set(field.get(), "set_placeholder", placeholder.get()); + Value(field.get(), "set_characterLimit", SettingsSearch::QueryLimit); + Value(field.get(), "set_lineType", 0); + Value(field.get(), "set_richText", false); + Value(field.get(), "set_restoreOriginalTextOnEscape", false); + Text(field.get(), query); + movedPanel = true; + Value(rect.get(), "set_offsetMax", Vec2{panelOffset.x, panelOffset.y - 90}); + SetActive(root.get(), true); + spdlog::info("[ModSettings] Search field opened"); + } catch (const std::exception& error) { + spdlog::warn("[ModSettings] Search unavailable: {}", error.what()); + CloseSettingsSearch(); + SetQuery({}); + RefreshActions(); + } +} +void InstallSettingsSearch() +{ + index.Build(Pages()); + installed = register_screen_manager_update_callback(Tick); +} +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/settings_search.h b/mods/src/settings/native/settings_search.h new file mode 100644 index 000000000..b7f68ab46 --- /dev/null +++ b/mods/src/settings/native/settings_search.h @@ -0,0 +1,14 @@ +#pragma once +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "interop.h" +#include "settings/page_catalog.h" +namespace mod_settings::native +{ +void RegisterSettingsSearch(PageCatalog& catalog); +void InstallSettingsSearch(); +void OpenSettingsSearch(Il2CppObject* controller); +void CloseSettingsSearch(); +bool FilterSettingsSearch(const PageCatalog::Page& page, std::string_view id); +void NavigateToSearchResult(std::string_view page, std::string_view item); +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/shortcut_popup.cc b/mods/src/settings/native/shortcut_popup.cc new file mode 100644 index 000000000..21a2bacbc --- /dev/null +++ b/mods/src/settings/native/shortcut_popup.cc @@ -0,0 +1,397 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "settings/shortcut_popup.h" +#include "action_widgets.h" +#include "patches/key.h" +#include "patches/screen_update_hook.h" +#include "prime/Vector3.h" +#include "row_style.h" +#include "settings/native_boolean_callback.h" +#include "settings/shortcut_popup_keys.h" +#include "ui_helpers.h" +#include "value_widgets.h" +#include +#include +#include +#include +#include + +namespace mod_settings::native +{ +namespace +{ + using namespace ui; + struct Vec2 { + float x, y; + }; + struct Color { + float r, g, b, a; + }; + struct Rect { + float x, y, width, height; + }; + struct Popup { + Il2CppGCHandle object{}, owner{}, status{}, proposed{}, current{}, title{}, confirmText{}, scroll{}; + std::array buttons{}, tokens{}; + ShortcutPopupCommands commands; + std::optional rendered; + ShortcutPopupKeys keys; + } popup; + Il2CppGCHandle navigationSystem = nullptr; + Il2CppGCHandle backController = nullptr; + bool previousNavigation = false, releaseNavigationNextTick = false; + bool previousBackEnabled = false; + NativeCallback clickCallback; + bool installed = false, invoking = false; + bool (*focused)() = nullptr; + + void ReleaseNavigation() + { + if (Alive(Target(backController))) { + Value(Target(backController), "set_enabled", previousBackEnabled); + if (Boolean(UiCall(Target(backController), "get_enabled")) != previousBackEnabled) + throw std::runtime_error("shortcut popup Back controller restore failed"); + } + Free(backController); + if (Alive(Target(navigationSystem))) { + Value(Target(navigationSystem), "set_sendNavigationEvents", previousNavigation); + if (Boolean(UiCall(Target(navigationSystem), "get_sendNavigationEvents")) != previousNavigation) + throw std::runtime_error("shortcut popup navigation restore failed"); + } + Free(navigationSystem); + releaseNavigationNextTick = false; + } + void HoldNavigation(Il2CppObject* settingsBack) + { + auto* cls = Class("UnityEngine.UI", "UnityEngine.EventSystems", "EventSystem"); + Root current(Static(IL2CppClassHelper(cls).GetMethodInfo("get_current", 0), nullptr)); + if (!current.get() || !Alive(settingsBack)) + throw std::runtime_error("shortcut popup navigation owner unavailable"); + if (current.get() != Target(navigationSystem) || settingsBack != Target(backController)) { + ReleaseNavigation(); + previousNavigation = Boolean(UiCall(current.get(), "get_sendNavigationEvents")); + Retain(navigationSystem, current.get()); + previousBackEnabled = Boolean(UiCall(settingsBack, "get_enabled")); + Retain(backController, settingsBack); + } + releaseNavigationNextTick = false; + // Suppress Unity Submit/Cancel/Move only; pointer clicks still work. Keep + // this held through teardown and key release so Escape cannot also go Back. + Value(current.get(), "set_sendNavigationEvents", false); + // Client263 BackButtonViewController.Update polls Input.GetKeyDown(Escape) + // directly, outside EventSystem and ScreenManager.Update. Suspend only the + // owning settings controller's poll until the dismissal key is released. + Value(settingsBack, "set_enabled", false); + } + void Layout(Il2CppObject* transform, Vec2 size, Vec2 position) + { + Value(transform, "set_anchorMin", Vec2{.5f, .5f}); + Value(transform, "set_anchorMax", Vec2{.5f, .5f}); + Value(transform, "set_pivot", Vec2{.5f, .5f}); + Value(transform, "set_sizeDelta", size); + Value(transform, "set_anchoredPosition", position); + } + Il2CppObject* NewObject(const char* name, Il2CppObject* parent) + { + auto* cls = UnityClass("GameObject"); + Root object(il2cpp_object_new(cls)); + Root label(reinterpret_cast(il2cpp_string_new(name))); + auto* ctor = IL2CppClassHelper(cls).GetMethodInfoSpecial( + ".ctor", [](auto n, auto p) { return n == 1 && Type(p[0], IL2CPP_TYPE_STRING); }); + void* args[] = {label.get()}; + Invoke(ctor, object.get(), args); + // The root is retained before any operation that can throw. Children are + // parented immediately; closing the root then cleans up partial construction. + if (!parent) + Retain(popup.object, object.get()); + Root transform(UiCall(object.get(), "get_transform")); + if (parent) { + bool world = false; + void* parentArgs[] = {parent, &world}; + UiCall(transform.get(), "SetParent", 2, parentArgs); + } + WithType(object.get(), "AddComponent", UnityClass("RectTransform")); + Value(object.get(), "set_layer", 5); // Unity UI layer. + return object.get(); + } + Il2CppObject* Image(Il2CppObject* object, Color color, Il2CppObject* sprite = nullptr) + { + Root image(WithType(object, "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "Image"))); + Value(image.get(), "set_color", color); + if (sprite) { + Set(image.get(), "set_sprite", sprite); + Value(image.get(), "set_type", 1); // Sliced. + } + return image.get(); + } + Il2CppObject* Label(Il2CppObject* parent, Il2CppObject* font, const char* name, Vec2 size, Vec2 position, + float fontSize, const std::string& content) + { + Root object(NewObject(name, parent)); + Root transform(UiCall(object.get(), "get_transform")); + Layout(transform.get(), size, position); + Root label(WithType(object.get(), "AddComponent", Class("Unity.TextMeshPro", "TMPro", "TextMeshProUGUI"))); + Set(label.get(), "set_font", font); + Value(label.get(), "set_fontSize", fontSize); + Value(label.get(), "set_alignment", 0x202); // Center. + Value(label.get(), "set_color", Color{.88f, .95f, .97f, 1}); + Value(label.get(), "set_raycastTarget", false); + Value(label.get(), "set_enableWordWrapping", true); + Text(label.get(), content); + return label.get(); + } + void Render() + { + if (!popup.commands.read) + return; + const auto value = popup.commands.read(); + if (popup.rendered == value) + return; + Text(Target(popup.title), value.title); + Text(Target(popup.current), "Current: " + value.current); + Text(Target(popup.proposed), value.proposed.empty() ? "Press a shortcut" : value.proposed); + Text(Target(popup.status), value.status); + Root statusTransform(UiCall(Target(popup.status), "get_transform")); + Root preferred(UiCall(Target(popup.status), "get_preferredHeight")); + if (!preferred.get() || !Type(il2cpp_class_get_type(preferred.get()->klass), IL2CPP_TYPE_R4)) + throw std::runtime_error("shortcut popup text height"); + Value(statusTransform.get(), "set_sizeDelta", + Vec2{548, std::max(180.f, *static_cast(il2cpp_object_unbox(preferred.get())))}); + Value(Target(popup.scroll), "set_verticalNormalizedPosition", 1.f); + Text(Target(popup.confirmText), value.confirmLabel); + Value(Target(popup.buttons[0]), "set_interactable", value.canConfirm); + Value(Target(popup.buttons[1]), "set_interactable", value.canRecord); + popup.rendered = value; + } + void Cancel() + { + auto cancel = popup.commands.cancel; + CloseShortcutPopup(); + if (cancel) + cancel(); + } + void Click(Il2CppObject* token, const MethodInfo*) + { + if (invoking || !OnUIThread() || !popup.commands.read) + return; + struct Scope { + Scope() + { invoking = true; } + ~Scope() + { invoking = false; } + } scope; + try { + if (!focused() || !Alive(Target(popup.object)) || !Alive(Target(popup.owner)) + || !Boolean(UiCall(Target(popup.owner), "get_activeInHierarchy"))) { + Cancel(); + return; + } + const auto presentation = popup.commands.read(); + // Copy before invocation: confirming/cancelling destroys the surface. + std::function command; + if (token == Target(popup.tokens[0]) && presentation.canConfirm) + command = popup.commands.confirm; + else if (token == Target(popup.tokens[1]) && presentation.canRecord) + command = popup.commands.record; + else if (token == Target(popup.tokens[2])) { + Cancel(); + return; + } + if (command) + command(); + Render(); + } catch (const std::exception& error) { + spdlog::warn("[Shortcuts] Popup command failed: {}", error.what()); + Cancel(); + } + } + void Button(Il2CppObject* parent, Il2CppObject* font, Il2CppObject* sprite, int index, const char* caption, + Vec2 position) + { + Root object(NewObject(caption, parent)); + Root transform(UiCall(object.get(), "get_transform")); + Layout(transform.get(), {174, 52}, position); + Root image(Image(object.get(), {.60f, .80f, .95f, 1}, sprite)); + Root button(WithType(object.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "Button"))); + Set(button.get(), "set_targetGraphic", image.get()); + Root text(Label(transform.get(), font, "Label", {166, 46}, {0, 0}, 21, caption)); + Retain(popup.buttons[index], button.get()); + if (index == 0) + Retain(popup.confirmText, text.get()); + Root token(il2cpp_object_new(il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"))); + Retain(popup.tokens[index], token.get()); + Root event(UiCall(button.get(), "get_onClick")); + auto* add = IL2CppClassHelper(event.get()->klass).GetMethodInfo("AddListener", 1); + if (!Instance(add, 1, IL2CPP_TYPE_VOID) || !Reference(add->parameters[0])) + throw std::runtime_error("shortcut popup button event"); + Root callback(MakeDelegate(il2cpp_class_from_type(add->parameters[0]), token.get(), clickCallback.method())); + void* args[] = {callback.get()}; + Invoke(add, event.get(), args); + } + void Tick() + { + try { + if (!popup.commands.read) { + if ((navigationSystem || backController) && !Key::shortcutCaptureActive) { + if (releaseNavigationNextTick) + ReleaseNavigation(); + else + releaseNavigationNextTick = true; // Also consume the release frame. + } else { + releaseNavigationNextTick = false; + } + return; + } + if (!focused() || !Alive(Target(popup.object)) || !Alive(Target(popup.owner)) + || !Boolean(UiCall(Target(popup.owner), "get_activeInHierarchy"))) { + Cancel(); + return; + } + const auto command = popup.keys.Tick( + popup.commands.read().canConfirm, Key::RawPressed(KeyCode::Return) || Key::RawPressed(KeyCode::KeypadEnter), + Key::RawDown(KeyCode::Return) || Key::RawDown(KeyCode::KeypadEnter), Key::RawDown(KeyCode::Escape)); + if (command == ShortcutPopupKey::Cancel) { + Cancel(); + return; + } + if (command == ShortcutPopupKey::Confirm) { + Root token(Target(popup.tokens[0])); + Click(token.get(), nullptr); // Same availability/conflict checks as the button. + } + Render(); + } catch (const std::exception& error) { + spdlog::warn("[Shortcuts] Popup closed: {}", error.what()); + Cancel(); + } + } + +} // namespace + +void CloseShortcutPopup() +{ + popup.commands = {}; + popup.rendered.reset(); + popup.keys = {}; + Key::shortcutPopupActive = false; + try { + if (Alive(Target(popup.object))) { + SetActive(Target(popup.object), false); + static auto* destroy = IL2CppClassHelper(UnityClass("Object")).GetMethodInfo("Destroy", 1); + void* args[] = {Target(popup.object)}; + Static(destroy, args); + } + } catch (...) { + spdlog::warn("[Shortcuts] Popup teardown unavailable"); + } + for (auto* handle : {&popup.object, &popup.owner, &popup.status, &popup.proposed, &popup.current, &popup.title, + &popup.confirmText, &popup.scroll}) + Free(*handle); + for (auto& handle : popup.buttons) + Free(handle); + for (auto& handle : popup.tokens) + Free(handle); +} + +bool OpenShortcutPopup(ShortcutPopupCommands commands) +{ + if (!installed || !OnUIThread() || popup.commands.read || !InvokingActionWidget()) + return false; + try { + Root host(InvokingActionWidget()); + Root hostObject(UiCall(host.get(), "get_gameObject")); + Root settings(WithType(hostObject.get(), "GetComponentInParent", + Class("Assembly-CSharp", "Digit.Prime.GameSettings", "GameSettingsViewController"), 2)); + if (!settings.get()) + throw std::runtime_error("shortcut popup settings controller unavailable"); + Root settingsBack(ReadField(settings.get(), Field(settings.get()->klass, "_backButtonViewController"))); + Root canvas( + WithType(hostObject.get(), "GetComponentInParent", Class("UnityEngine.UIModule", "UnityEngine", "Canvas"), 2)); + if (!canvas.get()) + throw std::runtime_error("shortcut popup canvas"); + Root parent(UiCall(canvas.get(), "get_transform")); + Root owner(UiCall(canvas.get(), "get_gameObject")); + Retain(popup.owner, owner.get()); + Root localizer(ReadField(host.get(), Field(host.get()->klass, "_label"))); + Root labelObject(UiCall(localizer.get(), "get_gameObject")); + Root source(WithType(labelObject.get(), "GetComponent", Class("Unity.TextMeshPro", "TMPro", "TMP_Text"))); + Root font(UiCall(source.get(), "get_font")); + if (!font.get()) + throw std::runtime_error("shortcut popup font"); + Root background(RowImage(host.get())); + Root sprite(background.get() ? UiCall(background.get(), "get_sprite") : nullptr); + + Root object(NewObject("CommunityMod_ShortcutPopup", nullptr)); + SetActive(object.get(), false); + Root transform(UiCall(object.get(), "get_transform")); + bool world = false; + void* parentArgs[] = {parent.get(), &world}; + UiCall(transform.get(), "SetParent", 2, parentArgs); + Value(transform.get(), "set_anchorMin", Vec2{0, 0}); + Value(transform.get(), "set_anchorMax", Vec2{1, 1}); + Value(transform.get(), "set_sizeDelta", Vec2{0, 0}); + Value(transform.get(), "set_anchoredPosition", Vec2{0, 0}); + UiCall(transform.get(), "SetAsLastSibling"); + Image(object.get(), {0, 0, 0, .45f}); // Blocks pointer input to the settings behind it. + Root panel(NewObject("Panel", transform.get())); + Root panelTransform(UiCall(panel.get(), "get_transform")); + Layout(panelTransform.get(), {620, 540}, {0, 0}); + // The row sprite is already dark; tinting it again obscures the popup surface. + Image(panel.get(), {.10f, .18f, .24f, 1}); + Root rect(UiCall(parent.get(), "get_rect")); + if (rect.get() && rect.get()->klass == UnityClass("Rect")) { + const auto value = *static_cast(il2cpp_object_unbox(rect.get())); + const auto scale = std::min({1.f, value.width / 650.f, value.height / 570.f}); + if (scale <= 0) + throw std::runtime_error("shortcut popup canvas size"); + Value(panelTransform.get(), "set_localScale", Vector3{scale, scale, 1}); + } + Retain(popup.title, Label(panelTransform.get(), font.get(), "Title", {560, 58}, {0, 214}, 29, "")); + Retain(popup.current, Label(panelTransform.get(), font.get(), "Current", {560, 45}, {0, 157}, 21, "")); + Retain(popup.proposed, Label(panelTransform.get(), font.get(), "Proposed", {560, 75}, {0, 82}, 32, "")); + Root viewport(NewObject("Warnings", panelTransform.get())); + Root viewportTransform(UiCall(viewport.get(), "get_transform")); + Layout(viewportTransform.get(), {560, 180}, {0, -59}); + WithType(viewport.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "RectMask2D")); + Image(viewport.get(), {0, 0, 0, .01f}); // Wheel/drag target even over empty text. + Root status(Label(viewportTransform.get(), font.get(), "Status", {548, 180}, {0, 0}, 20, "")); + Root statusTransform(UiCall(status.get(), "get_transform")); + Value(statusTransform.get(), "set_anchorMin", Vec2{.5f, 1}); + Value(statusTransform.get(), "set_anchorMax", Vec2{.5f, 1}); + Value(statusTransform.get(), "set_pivot", Vec2{.5f, 1}); + Value(status.get(), "set_alignment", 0x102); // Top center. + Retain(popup.status, status.get()); + Root scroll(WithType(viewport.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "ScrollRect"))); + Set(scroll.get(), "set_viewport", viewportTransform.get()); + Set(scroll.get(), "set_content", statusTransform.get()); + Value(scroll.get(), "set_horizontal", false); + Value(scroll.get(), "set_vertical", true); + Value(scroll.get(), "set_movementType", 2); // Clamped. + Value(scroll.get(), "set_scrollSensitivity", 24.f); + Retain(popup.scroll, scroll.get()); + Button(panelTransform.get(), font.get(), sprite.get(), 2, "Cancel", {-190, -220}); + Button(panelTransform.get(), font.get(), sprite.get(), 1, "Record again", {0, -220}); + Button(panelTransform.get(), font.get(), sprite.get(), 0, "Confirm", {190, -220}); + HoldNavigation(settingsBack.get()); + popup.commands = std::move(commands); + Key::shortcutPopupActive = true; + Render(); + SetActive(object.get(), true); + spdlog::info("[Shortcuts] Popup opened"); + return true; + } catch (const std::exception& error) { + spdlog::warn("[Shortcuts] Popup unavailable: {}", error.what()); + CloseShortcutPopup(); + return false; + } +} + +void InstallShortcutPopup() +{ + if (installed) + return; + auto* object = il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"); + const auto* ctor = object ? il2cpp_class_get_method_from_name(object, ".ctor", 0) : nullptr; + focused = il2cpp_resolve_icall_typed("UnityEngine.Application::get_isFocused()"); + installed = focused && clickCallback.Initialize(ctor, Click) && register_screen_manager_update_callback(Tick); +} +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/text_entry.cc b/mods/src/settings/native/text_entry.cc new file mode 100644 index 000000000..7c824c783 --- /dev/null +++ b/mods/src/settings/native/text_entry.cc @@ -0,0 +1,431 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "settings/text_entry.h" +#include "action_widgets.h" +#include "patches/key.h" +#include "patches/screen_update_hook.h" +#include "prime/Vector3.h" +#include "row_style.h" +#include "settings/native_boolean_callback.h" +#include "settings_search.h" +#include "str_utils.h" +#include "ui_helpers.h" +#include "value_widgets.h" +#include +#include +#include +#include +#include + +namespace mod_settings +{ +namespace +{ + using namespace native; + using namespace native::ui; + struct Vec2 { + float x, y; + }; + struct Color { + float r, g, b, a; + }; + struct Rect { + float x, y, width, height; + }; + struct Editor { + std::optional request; + Il2CppGCHandle object{}, owner{}, input{}, status{}, scroll{}, apply{}, back{}, navigation{}; + std::array tokens{}; + bool previousBack = false, previousNavigation = false; + bool backHeld = false, navigationHeld = false, invoking = false, releaseFrame = false, opening = false; + std::string value, error; + float statusHeight = 130; + } editor; + NativeCallback clickCallback; + bool installed = false; + bool (*focused)() = nullptr; + + void DestroySurface() + { + if (Alive(Target(editor.object))) { + SetActive(Target(editor.object), false); + void* args[]{Target(editor.object)}; + Static(IL2CppClassHelper(UnityClass("Object")).GetMethodInfo("Destroy", 1), args); + } + } + void ReleaseInput() + { + if (editor.backHeld && Alive(Target(editor.back))) + Value(Target(editor.back), "set_enabled", editor.previousBack); + if (editor.navigationHeld && Alive(Target(editor.navigation))) + Value(Target(editor.navigation), "set_sendNavigationEvents", editor.previousNavigation); + editor.backHeld = editor.navigationHeld = false; + Free(editor.back); + Free(editor.navigation); + editor.releaseFrame = false; + Key::textEntryActive = false; + } + bool AnyHeld() + { + static const auto supported = [] { + std::vector keys; + for (int i = 1; i < static_cast(KeyCode::Max); ++i) + if (!Key::Token(static_cast(i)).empty()) + keys.push_back(static_cast(i)); + return keys; + }(); + for (const auto key : supported) + if (Key::RawPressed(key)) + return true; + return false; + } + void HoldInput(Il2CppObject* back) + { + auto* cls = Class("UnityEngine.UI", "UnityEngine.EventSystems", "EventSystem"); + Root current(Static(IL2CppClassHelper(cls).GetMethodInfo("get_current", 0), nullptr)); + if (!Alive(back) || !current.get()) + throw std::runtime_error("text editor navigation owner unavailable"); + Retain(editor.back, back); + Retain(editor.navigation, current.get()); + editor.previousBack = Boolean(UiCall(back, "get_enabled")); + editor.previousNavigation = Boolean(UiCall(current.get(), "get_sendNavigationEvents")); + Key::textEntryActive = true; // Cleanup also drains a partially acquired hold. + editor.backHeld = true; + Value(back, "set_enabled", false); + editor.navigationHeld = true; + Value(current.get(), "set_sendNavigationEvents", false); + } + Il2CppObject* NewObject(const char* name, Il2CppObject* parent) + { + Root object(il2cpp_object_new(UnityClass("GameObject"))); + Root label(reinterpret_cast(il2cpp_string_new(name))); + const auto* ctor = IL2CppClassHelper(UnityClass("GameObject")).GetMethodInfoSpecial(".ctor", [](auto n, auto p) { + return n == 1 && Type(p[0], IL2CPP_TYPE_STRING); + }); + void* args[]{label.get()}; + Invoke(ctor, object.get(), args); + if (!parent) + Retain(editor.object, object.get()); + Root transform(UiCall(object.get(), "get_transform")); + if (parent) { + bool world = false; + void* parentArgs[]{parent, &world}; + UiCall(transform.get(), "SetParent", 2, parentArgs); + } + WithType(object.get(), "AddComponent", UnityClass("RectTransform")); + Value(object.get(), "set_layer", 5); + return object.get(); + } + void Layout(Il2CppObject* transform, Vec2 size, Vec2 position) + { + Value(transform, "set_anchorMin", Vec2{.5f, .5f}); + Value(transform, "set_anchorMax", Vec2{.5f, .5f}); + Value(transform, "set_pivot", Vec2{.5f, .5f}); + Value(transform, "set_sizeDelta", size); + Value(transform, "set_anchoredPosition", position); + } + Il2CppObject* Image(Il2CppObject* object, Color color) + { + Root image(WithType(object, "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "Image"))); + Value(image.get(), "set_color", color); + return image.get(); + } + Il2CppObject* Label(Il2CppObject* parent, Il2CppObject* font, const char* name, Vec2 size, Vec2 position, + float fontSize, const std::string& text) + { + Root child(NewObject(name, parent)); + Root transform(UiCall(child.get(), "get_transform")); + Layout(transform.get(), size, position); + Root label(WithType(child.get(), "AddComponent", Class("Unity.TextMeshPro", "TMPro", "TextMeshProUGUI"))); + Set(label.get(), "set_font", font); + Value(label.get(), "set_fontSize", fontSize); + Value(label.get(), "set_alignment", 0x202); + Value(label.get(), "set_color", Color{.88f, .95f, .97f, 1}); + Value(label.get(), "set_raycastTarget", false); + Value(label.get(), "set_richText", false); + Value(label.get(), "set_enableWordWrapping", true); + Text(label.get(), text); + return label.get(); + } + std::string ReadText() + { + Root value(UiCall(Target(editor.input), "get_text")); + if (!value.get() || !Type(il2cpp_class_get_type(value.get()->klass), IL2CPP_TYPE_STRING)) + throw std::runtime_error("text editor value unavailable"); + return to_string(reinterpret_cast(value.get())); + } + void ShowStatus(const std::string& text) + { + Text(Target(editor.status), text); + Root preferred(UiCall(Target(editor.status), "get_preferredHeight")); + if (!preferred.get() || !Type(il2cpp_class_get_type(preferred.get()->klass), IL2CPP_TYPE_R4)) + throw std::runtime_error("text editor help layout unavailable"); + Root transform(UiCall(Target(editor.status), "get_transform")); + Value(transform.get(), "set_sizeDelta", + Vec2{590, std::max(editor.statusHeight, *static_cast(il2cpp_object_unbox(preferred.get())))}); + Value(Target(editor.scroll), "set_verticalNormalizedPosition", 1.f); + } + void Validate() + { + editor.value = ReadText(); + editor.error = editor.request->validate ? editor.request->validate(editor.value) : std::string{}; + ShowStatus(editor.error.empty() ? editor.request->help : editor.error); + Value(Target(editor.apply), "set_interactable", editor.error.empty()); + } + void Cancel() + { + auto cancel = editor.request ? editor.request->cancel : std::function{}; + CloseTextEntry(); + if (cancel) { + try { + cancel(); + } catch (...) { + Warn("text editor cancel callback unavailable"); + } + } + } + void Apply() + { + Validate(); + if (!editor.error.empty()) + return; + // Keep the request alive if commit synchronously closes the settings page. + const auto request = *editor.request; + const auto value = editor.value; + if (!request.commit(value)) { + if (editor.request) + ShowStatus("Setting changed or could not be applied. Cancel and reopen to try again."); + return; + } + CloseTextEntry(); + RefreshActions(); + } + void Click(Il2CppObject* token, const MethodInfo*) + { + if (editor.invoking || !OnUIThread() || !editor.request) + return; + struct Scope { + Scope() + { editor.invoking = true; } + ~Scope() + { editor.invoking = false; } + } scope; + try { + if (editor.opening) + return; + if (!focused() || !Alive(Target(editor.object)) || !Alive(Target(editor.owner))) { + Cancel(); + return; + } + if (token == Target(editor.tokens[0])) + Apply(); + else if (token == Target(editor.tokens[1])) + Cancel(); + } catch (const std::exception& error) { + spdlog::warn("[ModSettings] Text edit failed: {}", error.what()); + Cancel(); + } + } + void Button(Il2CppObject* parent, Il2CppObject* font, int index, const char* title, float x) + { + Root object(NewObject(title, parent)); + Root transform(UiCall(object.get(), "get_transform")); + Layout(transform.get(), {190, 52}, {x, -166}); + Root image(Image(object.get(), {.18f, .34f, .43f, 1})); + Root button(WithType(object.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "Button"))); + Set(button.get(), "set_targetGraphic", image.get()); + Label(transform.get(), font, "Label", {178, 46}, {0, 0}, 24, title); + if (index == 0) + Retain(editor.apply, button.get()); + Root token(il2cpp_object_new(il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"))); + Retain(editor.tokens[index], token.get()); + Root event(UiCall(button.get(), "get_onClick")); + const auto* add = IL2CppClassHelper(event.get()->klass).GetMethodInfo("AddListener", 1); + if (!Instance(add, 1, IL2CPP_TYPE_VOID) || !Reference(add->parameters[0])) + throw std::runtime_error("text editor button event"); + Root callback(MakeDelegate(il2cpp_class_from_type(add->parameters[0]), token.get(), clickCallback.method())); + void* args[]{callback.get()}; + Invoke(add, event.get(), args); + } + void Tick() + { + if (!editor.request && !Key::textEntryActive) + return; + try { + if (!editor.request) { + // Consume the dismissal frame and all held keys, including a mouse click. + if (!focused() || AnyHeld()) + editor.releaseFrame = false; + else if (editor.releaseFrame) + ReleaseInput(); + else + editor.releaseFrame = true; + return; + } + if (!focused() || !Alive(Target(editor.object)) || !Alive(Target(editor.owner)) + || !Boolean(UiCall(Target(editor.owner), "get_activeInHierarchy"))) { + Cancel(); + return; + } + // A key or pointer click that opened the editor cannot also apply/cancel it. + if (editor.opening) { + if (AnyHeld()) + return; + editor.opening = false; + } + if (Key::RawDown(KeyCode::Escape)) { + Cancel(); + return; + } + if (ReadText() != editor.value) + Validate(); + // Enter explicitly applies; multiline TOML arrays are entered as a single + // TOML expression, so keyboard Submit never adds an invisible newline. + if (Key::RawDown(KeyCode::Return) || Key::RawDown(KeyCode::KeypadEnter)) + Apply(); + } catch (const std::exception& error) { + spdlog::warn("[ModSettings] Text editor closed: {}", error.what()); + Cancel(); + } + } +} // namespace + +bool TextEntryActive() +{ return editor.request.has_value(); } +void CloseTextEntry() +{ + editor.request.reset(); + editor.releaseFrame = false; + editor.opening = false; + try { + DestroySurface(); + } catch (...) { + Warn("text editor cleanup unavailable"); + } + for (auto* handle : {&editor.object, &editor.owner, &editor.input, &editor.status, &editor.scroll, &editor.apply}) + Free(*handle); + for (auto& token : editor.tokens) + Free(token); +} +bool OpenTextEntry(TextEntryRequest request) +{ + if (!OnUIThread() || !request.commit || editor.request || Key::textEntryActive || Key::shortcutPopupActive + || Key::shortcutCaptureActive || !InvokingActionWidget()) + return false; + try { + if (!installed) { + auto* object = il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"); + const auto* ctor = object ? il2cpp_class_get_method_from_name(object, ".ctor", 0) : nullptr; + focused = il2cpp_resolve_icall_typed("UnityEngine.Application::get_isFocused()"); + installed = focused && clickCallback.Initialize(ctor, Click) && register_screen_manager_update_callback(Tick); + if (!installed) + return false; + } + CloseSettingsSearch(); + Root host(InvokingActionWidget()); + Root hostObject(UiCall(host.get(), "get_gameObject")); + Root settings(WithType(hostObject.get(), "GetComponentInParent", + Class("Assembly-CSharp", "Digit.Prime.GameSettings", "GameSettingsViewController"), 2)); + if (!settings.get()) + throw std::runtime_error("text editor settings controller unavailable"); + Root back(ReadField(settings.get(), Field(settings.get()->klass, "_backButtonViewController"))); + Root canvas( + WithType(hostObject.get(), "GetComponentInParent", Class("UnityEngine.UIModule", "UnityEngine", "Canvas"), 2)); + Root parent(UiCall(canvas.get(), "get_transform")); + Retain(editor.owner, UiCall(settings.get(), "get_gameObject")); + Root localizer(ReadField(host.get(), Field(host.get()->klass, "_label"))); + Root labelObject(UiCall(localizer.get(), "get_gameObject")); + Root source(WithType(labelObject.get(), "GetComponent", Class("Unity.TextMeshPro", "TMPro", "TMP_Text"))); + Root font(UiCall(source.get(), "get_font")); + if (!font.get()) + throw std::runtime_error("text editor font unavailable"); + Root root(NewObject("CommunityMod_TextEditor", nullptr)); + SetActive(root.get(), false); + Root transform(UiCall(root.get(), "get_transform")); + bool world = false; + void* args[]{parent.get(), &world}; + UiCall(transform.get(), "SetParent", 2, args); + Value(transform.get(), "set_anchorMin", Vec2{0, 0}); + Value(transform.get(), "set_anchorMax", Vec2{1, 1}); + Value(transform.get(), "set_sizeDelta", Vec2{0, 0}); + Value(transform.get(), "set_anchoredPosition", Vec2{0, 0}); + UiCall(transform.get(), "SetAsLastSibling"); + Image(root.get(), {0, 0, 0, .5f}); + Root panel(NewObject("Panel", transform.get())); + Root panelTransform(UiCall(panel.get(), "get_transform")); + Layout(panelTransform.get(), {680, 430}, {0, 0}); + Image(panel.get(), {.10f, .18f, .24f, 1}); + Root rect(UiCall(parent.get(), "get_rect")); + if (rect.get() && rect.get()->klass == UnityClass("Rect")) { + const auto bounds = *static_cast(il2cpp_object_unbox(rect.get())); + const auto scale = std::min({1.f, bounds.width / 710.f, bounds.height / 460.f}); + if (scale <= 0) + throw std::runtime_error("text editor canvas size unavailable"); + Value(panelTransform.get(), "set_localScale", Vector3{scale, scale, 1}); + } + Label(panelTransform.get(), font.get(), "Title", {620, 60}, {0, 162}, 28, request.title); + Root inputObject(NewObject("Input", panelTransform.get())); + Root inputRect(UiCall(inputObject.get(), "get_transform")); + Layout(inputRect.get(), {610, 80}, {0, 72}); + Root background(Image(inputObject.get(), {.06f, .12f, .16f, 1})); + Root viewport(NewObject("Text Area", inputRect.get())); + Root viewportRect(UiCall(viewport.get(), "get_transform")); + Layout(viewportRect.get(), {580, 64}, {0, 0}); + WithType(viewport.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "RectMask2D")); + Root text(Label(viewportRect.get(), font.get(), "Text", {580, 64}, {0, 0}, 25, "")); + Value(text.get(), "set_alignment", 0x201); + Value(text.get(), "set_enableWordWrapping", false); + Root field(WithType(inputObject.get(), "AddComponent", Class("Unity.TextMeshPro", "TMPro", "TMP_InputField"))); + Retain(editor.input, field.get()); + Set(field.get(), "set_targetGraphic", background.get()); + Set(field.get(), "set_textViewport", viewportRect.get()); + Set(field.get(), "set_textComponent", text.get()); + Value(field.get(), "set_characterLimit", std::max(32768, static_cast(request.value.size()))); + Value(field.get(), "set_lineType", 0); + Value(field.get(), "set_richText", false); + Value(field.get(), "set_restoreOriginalTextOnEscape", false); + Value(field.get(), "set_readOnly", request.read_only); + Text(field.get(), request.value); + const bool details = request.read_only && request.value.empty(); + if (details) + SetActive(inputObject.get(), false); + editor.statusHeight = details ? 250.f : 130.f; + Root statusArea(NewObject("Help", panelTransform.get())); + Root statusRect(UiCall(statusArea.get(), "get_transform")); + Layout(statusRect.get(), {610, editor.statusHeight}, {0, details ? 2.f : -58.f}); + WithType(statusArea.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "RectMask2D")); + Image(statusArea.get(), {0, 0, 0, .01f}); + Root status(Label(statusRect.get(), font.get(), "Status", {590, editor.statusHeight}, {0, 0}, 21, request.help)); + Root statusTransform(UiCall(status.get(), "get_transform")); + Value(statusTransform.get(), "set_anchorMin", Vec2{.5f, 1}); + Value(statusTransform.get(), "set_anchorMax", Vec2{.5f, 1}); + Value(statusTransform.get(), "set_pivot", Vec2{.5f, 1}); + Value(status.get(), "set_alignment", 0x102); + Retain(editor.status, status.get()); + Root scroll(WithType(statusArea.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "ScrollRect"))); + Set(scroll.get(), "set_viewport", statusRect.get()); + Set(scroll.get(), "set_content", statusTransform.get()); + Value(scroll.get(), "set_horizontal", false); + Value(scroll.get(), "set_vertical", true); + Value(scroll.get(), "set_movementType", 2); + Value(scroll.get(), "set_scrollSensitivity", 24.f); + Retain(editor.scroll, scroll.get()); + Button(panelTransform.get(), font.get(), 1, "Cancel", -124); + Button(panelTransform.get(), font.get(), 0, request.apply_label.c_str(), 124); + HoldInput(back.get()); + editor.request = std::move(request); + editor.opening = true; + Validate(); + SetActive(root.get(), true); + if (!editor.request->read_only) { + UiCall(field.get(), "Select"); + UiCall(field.get(), "ActivateInputField"); + } + return true; + } catch (const std::exception& error) { + spdlog::warn("[ModSettings] Text editor unavailable: {}", error.what()); + CloseTextEntry(); + return false; + } +} +} // namespace mod_settings +#endif diff --git a/mods/src/settings/native/ui_helpers.h b/mods/src/settings/native/ui_helpers.h new file mode 100644 index 000000000..f16ffe8e9 --- /dev/null +++ b/mods/src/settings/native/ui_helpers.h @@ -0,0 +1,74 @@ +#pragma once +#include "interop.h" +#include + +namespace mod_settings::native::ui +{ +inline Il2CppClass* Class(const char* assembly, const char* ns, const char* name) +{ + auto* cls = il2cpp_get_class_helper(assembly, ns, name).get_cls(); + if (!cls) + throw std::runtime_error(std::string("settings UI class unavailable: ") + assembly + ":" + ns + "." + name); + return cls; +} +inline Il2CppClass* UnityClass(const char* name) +{ return Class("UnityEngine.CoreModule", "UnityEngine", name); } +inline Il2CppObject* Static(const MethodInfo* method, void** args) +{ + Il2CppObject* result = nullptr; + if (!Il2CppRuntime::TryInvoke(method, nullptr, args, &result)) + throw std::runtime_error("settings UI static invocation"); + return result; +} +inline Il2CppObject* UiCall(Il2CppObject* object, const char* name, int count = 0, void** args = nullptr) +{ + try { + return Call(object, name, count, args); + } catch (const std::exception&) { + throw std::runtime_error(std::string("settings UI call: ") + + (object ? il2cpp_class_get_name(object->klass) : "null") + "." + name); + } +} +inline bool Alive(Il2CppObject* object) +{ + if (!object) + return false; + static const auto* method = IL2CppClassHelper(UnityClass("Object")).GetMethodInfo("op_Implicit", 1); + void* args[] = {object}; + Root result(Static(method, args)); + return Boolean(result.get()); +} +inline void Retain(Il2CppGCHandle& handle, Il2CppObject* object) +{ + Free(handle); + handle = object ? il2cpp_gchandle_new(object, false) : nullptr; + if (!handle) + throw std::runtime_error("settings UI root"); +} +inline void Set(Il2CppObject* object, const char* method, void* value) +{ + void* args[] = {value}; + UiCall(object, method, 1, args); +} +template void Value(Il2CppObject* object, const char* method, T value) +{ Set(object, method, &value); } +inline Il2CppObject* WithType(Il2CppObject* object, const char* method, Il2CppClass* type, int arity = 1) +{ + auto* target = IL2CppClassHelper(object->klass).GetMethodInfoSpecial(method, [arity](auto count, auto params) { + return count == arity && Reference(params[0]) + && (arity == 1 || (arity == 2 && Type(params[1], IL2CPP_TYPE_BOOLEAN))) + && std::strcmp(il2cpp_class_get_name(il2cpp_class_from_type(params[0])), "Type") == 0; + }); + if (!target) + throw std::runtime_error(std::string("settings UI typed method unavailable: ") + method); + Root reflection(reinterpret_cast(il2cpp_type_get_object(il2cpp_class_get_type(type)))); + bool includeInactive = false; + void* args[] = {reflection.get(), &includeInactive}; + return Invoke(target, object, args); +} +inline void Text(Il2CppObject* text, const std::string& value) +{ + Root string(reinterpret_cast(il2cpp_string_new(value.c_str()))); + Set(text, "set_text", string.get()); +} +} // namespace mod_settings::native::ui diff --git a/mods/src/settings/native/value_widget_record.h b/mods/src/settings/native/value_widget_record.h new file mode 100644 index 000000000..a4e2fddbf --- /dev/null +++ b/mods/src/settings/native/value_widget_record.h @@ -0,0 +1,38 @@ +#pragma once + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "row_style.h" +#include "settings/native_view_state.h" +#include "value_widgets.h" +#include + +namespace mod_settings::native +{ +// Shared only by value widgets and their styling implementation. Pages and +// commands query busy state without borrowing or mutating these lifetime records. +// Stable weak records, sized once from registered controls before pages can bind: no page, context, delegate, or +// account is retained by UI bookkeeping. Records are released on native unbind and reclaimed on next bind if Unity +// destroys a widget without sending that notification. +struct ValueWidget { + RowTint tint, checkTint; + Il2CppGCHandle choiceBackground = nullptr, choiceOverrideBefore = nullptr, choiceCheck = nullptr; + bool choiceStyled = false, pressed = false; + Il2CppGCHandle widget = nullptr, context = nullptr, label = nullptr; + Il2CppGCHandle selectionControl = nullptr; + std::array indicators{}; + std::array activeBefore{}; + bool overridden = false; + bool hidden = false; + bool disabled = false; + bool interactableBefore = false; + bool rendering = false; + bool binding = false; + bool requesting = false; + bool clearing = false; + bool preserveNextRefresh = false; + std::optional state; +}; + +} // namespace mod_settings::native + +#endif diff --git a/mods/src/settings/native/value_widgets.cc b/mods/src/settings/native/value_widgets.cc new file mode 100644 index 000000000..ce308cfc4 --- /dev/null +++ b/mods/src/settings/native/value_widgets.cc @@ -0,0 +1,1033 @@ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "action_widgets.h" +#include "page_navigation.h" +#include "patches/parts/fc_confirmation_reset.h" +#include "settings/forbidden_tech.h" +#include "settings/native_boolean_callback.h" +#include "value_widget_record.h" +#include +#include +#include +#include +#include +#include + +namespace mod_settings::native +{ +namespace +{ + constexpr const char* CategoryKey = "game_settings_category_7"; + bool active = false, installing = false; + std::thread::id uiThread; + NativeCallback getter; + NativeCallback setter; + NativeCallback query, selectionGetter; + NativeCallback selectionSetter; + NativeCallback sliderGetter; + NativeCallback sliderSetter; + bool selectionActive = false, sliderActive = false; +} // namespace +bool SelectionActive() +{ return selectionActive; } +bool SliderActive() +{ return sliderActive; } +const MethodInfo* QueryMethod() +{ return query.method(); } +BooleanSetting* SettingFor(Il2CppObject* context) +{ + auto& fc = FleetCommanderConfirmationSetting(); + if (HasLabel(context, fc.id().c_str())) + return &fc; + auto& ft = ForbiddenTechConfirmationSetting(); + if (HasLabel(context, ft.id().c_str())) + return &ft; + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + if (HasLabel(context, setting->id().c_str())) + return setting; + return nullptr; +} +ValueWidgetMetadata& ToggleMeta() +{ + static ValueWidgetMetadata metadata; + return metadata; +} +ValueWidgetMetadata& SelectionMeta() +{ + static ValueWidgetMetadata metadata(true); + return metadata; +} +ValueWidgetMetadata& SliderMeta() +{ + static ValueWidgetMetadata metadata(false, true); + return metadata; +} +ValueWidgetMetadata& WidgetMeta(Il2CppObject* widget) +{ + if (sliderActive && widget && widget->klass == SliderMeta().widget.get_cls()) + return SliderMeta(); + return selectionActive && widget && widget->klass == SelectionMeta().widget.get_cls() ? SelectionMeta() + : ToggleMeta(); +} +std::pair ChoiceFor(Il2CppObject* context) +{ + for (const auto& page : Pages()) + for (auto* choice : page.Controls()) + for (int i = 0; i < static_cast(choice->labels().size()); ++i) + if (HasLabel(context, choice->item_id(i).c_str())) + return {choice, i}; + return {nullptr, 0}; +} + +SliderSetting* SliderFor(Il2CppObject* context) +{ + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + if (HasLabel(context, setting->state().id().c_str())) + return setting; + return nullptr; +} +std::deque& ValueViews() +{ + static std::deque views(8); + return views; +} +ValueWidget* renderingView = nullptr; +ValueWidget* bindingView = nullptr; +void Restore(ValueWidget& view) +{ + RestoreTint(view.tint); + if (view.disabled) { + if (auto* control = Target(view.selectionControl)) { + void* args[] = {&view.interactableBefore}; + Call(control, "set_interactable", 1, args); + } + view.disabled = false; + } + if (view.overridden) { + if (auto* label = Target(view.label)) + Call(label, "ClearTextOverride"); + view.overridden = false; + } + if (view.hidden) { + for (std::size_t i = 0; i < view.indicators.size(); ++i) + if (auto* object = Target(view.indicators[i])) + SetActive(object, view.activeBefore[i]); + view.hidden = false; + } +} +void Clear(ValueWidget& view) +{ + if (view.clearing) + return; + struct Scope { + ValueWidget& view; + explicit Scope(ValueWidget& view) + : view(view) + { view.clearing = true; } + ~Scope() + { view.clearing = false; } + } scope(view); + try { + Restore(view); + } catch (...) { + Warn("settings view restoration unavailable"); + } + // Restoring interactability synchronously calls DoStateTransition. Suppress + // presentation reentry until every override is restored and the slot detached. + ClearChoiceStyle(view); + Free(view.widget); + Free(view.context); + Free(view.label); + Free(view.selectionControl); + for (auto& handle : view.indicators) + Free(handle); + if (view.state) + view.state->Unbind(); + // Keep this object alive through reentrant release during read/write. + // Track replaces it only after its rendering/request scope has returned. + view.overridden = view.hidden = view.disabled = view.preserveNextRefresh = false; +} +ValueWidget* FindValueWidget(Il2CppObject* widget) +{ + for (auto& view : ValueViews()) + if (Target(view.widget) == widget) + return &view; + return nullptr; +} +bool OwnsValueContext(Il2CppObject* context) +{ + if (!context) + return false; + const bool selection = selectionActive && context->klass == SelectionMeta().row.get_cls(); + const bool slider = sliderActive && context->klass == SliderMeta().row.get_cls(); + if (!selection && !slider && context->klass != ToggleMeta().row.get_cls()) + return false; + auto& meta = slider ? SliderMeta() : selection ? SelectionMeta() : ToggleMeta(); + auto* callback = reinterpret_cast(ReadField(context, meta.queryField)); + return callback && callback->method == query.method() && callback->method_ptr == query.method()->methodPointer + && (slider ? SliderFor(context) != nullptr + : selection ? ChoiceFor(context).first != nullptr + : SettingFor(context) != nullptr); +} +bool ChildOf(Il2CppObject* transform, Il2CppObject* parent) +{ + void* args[] = {parent}; + return Il2CppChecked::Boolean(Call(transform, "IsChildOf", 1, args)); +} +ValueWidget& Track(Il2CppObject* widget, Il2CppObject* context) +{ + for (auto& view : ValueViews()) { + if (Target(view.widget) || view.rendering || view.binding || view.requesting || view.clearing) + continue; + Clear(view); + auto& metadata = WidgetMeta(widget); + Root label(ReadField(widget, metadata.labelField)); + Root widgetTransform(Call(widget, "get_transform")); + Root labelTransform(Call(label.get(), "get_transform")); + std::array indicators{ReadField(widget, metadata.toggleField), + ReadField(widget, metadata.stateField)}; + Root first(Call(indicators[0], "get_gameObject")); + Root second(Call(indicators[1], "get_gameObject")); + indicators = {first.get(), second.get()}; + Root selectionControl(metadata.selection || metadata.slider ? ReadField(widget, metadata.toggleField) : nullptr); + if (metadata.selection) { + // Selection prefabs can put the toggle/animator on the entire row. Never + // hide those containers: unknown selection renders -1 and disables input. + Root transform(Call(selectionControl.get(), "get_transform")); + if (!ChildOf(transform.get(), widgetTransform.get()) || !ChildOf(labelTransform.get(), widgetTransform.get())) + throw std::runtime_error("selection control hierarchy"); + (void)Il2CppChecked::Boolean(Call(selectionControl.get(), "get_interactable")); + } else { + // Boolean rows suppress unknown ON/OFF by hiding only detached indicators. + for (auto* indicator : indicators) { + Root transform(Call(indicator, "get_transform")); + if (transform.get() == widgetTransform.get() || !ChildOf(transform.get(), widgetTransform.get()) + || ChildOf(labelTransform.get(), transform.get())) + throw std::runtime_error("settings indicator hierarchy"); + } + } + try { + auto weak = [](Il2CppObject* object) { + auto handle = il2cpp_gchandle_new_weakref(object, false); + if (!handle) + throw std::runtime_error("settings weak root"); + return handle; + }; + view.widget = weak(widget); + view.context = weak(context); + if (metadata.slider) { + auto* setting = SliderFor(context); + if (!setting) + throw std::runtime_error("slider owner missing"); + view.state.emplace(*setting); + } else if (metadata.selection) { + auto [setting, index] = ChoiceFor(context); + if (!setting) + throw std::runtime_error("selection owner missing"); + view.state.emplace(*setting, index); + } else { + auto* setting = SettingFor(context); + if (!setting) + throw std::runtime_error("settings owner missing"); + view.state.emplace(*setting); + } + view.label = weak(label.get()); + if (metadata.selection || metadata.slider) + view.selectionControl = weak(selectionControl.get()); + if (!metadata.selection) + for (std::size_t i = 0; i < indicators.size(); ++i) + view.indicators[i] = weak(indicators[i]); + } catch (...) { + Clear(view); + throw; + } + return view; + } + throw std::runtime_error("settings view capacity"); +} + +bool GetEnabled(Il2CppObject*, const MethodInfo*) +{ + // Native bool signatures cannot express unknown. Only the owned render scope + // consumes this placeholder; its indicators are suppressed when value is empty. + return renderingView && renderingView->state ? renderingView->state->value().value_or(false) : false; +} +int GetSelected(Il2CppObject*, const MethodInfo*) +{ return renderingView && renderingView->state ? renderingView->state->selected() : -1; } +void SetSelected(Il2CppObject*, int, const MethodInfo*) {} +float GetNumber(Il2CppObject*, const MethodInfo*) +{ return renderingView && renderingView->state ? renderingView->state->number() : 0.0f; } +void SetNumber(Il2CppObject*, float, const MethodInfo*) {} +void SetEnabled(Il2CppObject*, bool, const MethodInfo*) +{ + // Deliberately inert. Only OnToggleValueChanged with a live view snapshot can + // authorize a write; rendering and reflection cannot mutate game preferences. +} +int QueryState(Il2CppObject*, const MethodInfo*) +{ return 0; } + +Il2CppObject* Category(Il2CppObject* container, int depth, int& remaining) +{ + if (!container || depth > 3 || --remaining < 0) + return nullptr; + if (HasLabel(container, CategoryKey)) + return container; + if (!il2cpp_class_get_method_from_name(container->klass, "get_Children", 0)) + return nullptr; + Root children(Call(container, "get_Children")); + for (int i = 0, count = Count(children.get()); i < count && remaining > 0; ++i) + if (auto* found = Category(Item(children.get(), i), depth + 1, remaining)) + return found; + return nullptr; +} +void AddBooleanRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* category, BooleanSetting& setting) +{ + if (setting.Observe().state.availability == Availability::Unsupported) + return; + Root children(Call(category, "get_Children")); + const int before = Count(children.get()); + for (int i = 0; i < before; ++i) + if (HasLabel(Item(children.get(), i), setting.id().c_str())) + return; + if (before == PageCatalog::NativeChildLimit) + throw std::runtime_error("settings category full"); + auto& m = ToggleMeta(); + Root get(MakeDelegate(il2cpp_class_from_type(m.addToggle->parameters[2]), director, getter.method())); + Root set(MakeDelegate(il2cpp_class_from_type(m.addToggle->parameters[3]), director, setter.method())); + Root state(MakeDelegate(il2cpp_class_from_type(m.querySetter->parameters[0]), director, query.method())); + Root label(reinterpret_cast(il2cpp_string_new(setting.id().c_str()))); + void* args[] = {category, label.get(), get.get(), set.get()}; + Invoke(m.addToggle, context, args); + if (Count(children.get()) != before + 1) + throw std::runtime_error("settings row insertion"); + Root row(Item(children.get(), before)); + try { + if (row.get()->klass != m.row.get_cls() || !HasLabel(row.get(), setting.id().c_str())) + throw std::runtime_error("settings row identity"); + void* stateArgs[] = {state.get()}; + Invoke(m.querySetter, row.get(), stateArgs); + } catch (...) { + void* removeArgs[] = {row.get()}; + Call(category, "RemoveChild", 1, removeArgs); + throw; + } +} + +void AddRow(Il2CppObject* director, Il2CppObject* context) +{ + Root root(Call(context, "get_RootOption")); + int remaining = 128; + Root category(Category(root.get(), 0, remaining)); + if (!category.get()) + throw std::runtime_error("settings confirmation category"); + AddBooleanRow(director, context, category.get(), FleetCommanderConfirmationSetting()); + AddBooleanRow(director, context, category.get(), ForbiddenTechConfirmationSetting()); +} +void Render(ValueWidget& view, auto original, Il2CppObject* widget) +{ + if (view.rendering) + return; + Root boundContext(Target(view.context)); + struct Scope { + ValueWidget& view; + ValueWidget* previous; + NativeViewState::RenderScope suppress; + Scope(ValueWidget& view) + : view(view) + , previous(renderingView) + , suppress(*view.state) + { + view.rendering = true; + renderingView = &view; + } + ~Scope() + { + renderingView = previous; + view.rendering = false; + } + } scope(view); + Restore(view); + original(widget); + if (Target(view.widget) != widget || Target(view.context) != boundContext.get()) + return; + Root label(Target(view.label)); + std::string text = view.state->label(); + // The native row has limited label width: "Change not applied; try again" was + // visibly truncated after "; tr" alongside the FC label. Keep these suffixes + // short; recheck the full label at supported UI scales when changing wording. + if (!view.state->known()) + text += " — " + std::string(view.state->unavailableReason()); + else if (view.state->failed()) + text += " — Retry"; + else if (!view.state->enabled() && !view.state->disabledReason().empty()) + text += " — " + std::string(view.state->disabledReason()); + const auto status = view.state->status_text(); + if (!status.empty()) + text += "\n" + status + ""; + // An enabled slider belongs to the selected mode above it. Use a quiet cyan + // accent, not the native white selection fill (the slider is not a choice). + if (WidgetMeta(widget).slider && view.state->enabled()) { + text = "" + text + ""; + TintRow(view.tint, widget, {0.70f, 1.0f, 1.0f, 1.0f}); + } + Root message(reinterpret_cast(il2cpp_string_new(text.c_str()))); + void* args[] = {message.get()}; + Call(label.get(), "OverrideLocalizedText", 1, args); + view.overridden = true; + TryStyleChoice(view); + if (!view.state->enabled()) { + if (auto* control = Target(view.selectionControl)) { + view.interactableBefore = Il2CppChecked::Boolean(Call(control, "get_interactable")); + view.disabled = true; + bool interactable = false; + void* controlArgs[] = {&interactable}; + Call(control, "set_interactable", 1, controlArgs); + if (WidgetMeta(widget).selection || view.state->known()) + return; + } + // Capture all native values first (the two components may share a node). + for (std::size_t i = 0; i < view.indicators.size(); ++i) + view.activeBefore[i] = Il2CppChecked::Boolean(Call(Target(view.indicators[i]), "get_activeSelf")); + view.hidden = true; + for (auto handle : view.indicators) + SetActive(Target(handle), false); + } +} +void HideUnsupported(Il2CppObject* widget) +{ + try { + Root object(Call(widget, "get_gameObject")); + SetActive(object.get(), false); + } catch (...) { + Warn("unsupported settings widget hiding unavailable"); + } + Warn("unsupported settings widget unavailable"); +} +bool OnUIThread() +{ return active && std::this_thread::get_id() == uiThread; } + +void AddChoiceRows(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, ChoiceSetting& setting) +{ + if (!selectionActive) + return; + auto& m = SelectionMeta(); + const auto* add = m.context.GetMethodInfo("AddSelection", 6); + Root children(Call(parent, "get_Children")); + const int before = Count(children.get()); + const auto count = setting.labels().size(); + if (before + count > PageCatalog::NativeChildLimit) + throw std::runtime_error("selection category capacity"); + Root values(reinterpret_cast( + il2cpp_array_new(il2cpp_class_from_name(il2cpp_get_corlib(), "System", "String"), count))); + for (std::size_t i = 0; i < count; ++i) { + Root value(reinterpret_cast(il2cpp_string_new(setting.item_id(static_cast(i)).c_str()))); + auto* array = reinterpret_cast(values.get()); + il2cpp_gc_wbarrier_set_field(values.get(), reinterpret_cast(&array->vector[i]), value.get()); + } + Root label(reinterpret_cast(il2cpp_string_new(setting.state().id().c_str()))); + Root category(reinterpret_cast(il2cpp_string_new(""))); + Root get(MakeDelegate(il2cpp_class_from_type(add->parameters[3]), director, selectionGetter.method())); + Root set(MakeDelegate(il2cpp_class_from_type(add->parameters[4]), director, selectionSetter.method())); + void* args[] = {parent, label.get(), values.get(), get.get(), set.get(), category.get()}; + Invoke(add, context, args); + if (Count(children.get()) != before + static_cast(count)) + throw std::runtime_error("selection row insertion"); + for (int i = 0; i < static_cast(count); ++i) { + Root row(Item(children.get(), before + i)); + if (row.get()->klass != m.row.get_cls()) + throw std::runtime_error("selection row class"); + Root index(Call(row.get(), "get_Index")); + if (!index.get() || !Type(il2cpp_class_get_type(index.get()->klass), IL2CPP_TYPE_I4) + || *static_cast(il2cpp_object_unbox(index.get())) != i) + throw std::runtime_error("selection row index"); + Root text(Call(row.get(), "get_LabelContext")); + Root id(reinterpret_cast(il2cpp_string_new(setting.item_id(i).c_str()))); + void* labelArgs[] = {id.get()}; + Call(text.get(), "set_Identifier", 1, labelArgs); + Root state(MakeDelegate(il2cpp_class_from_type(m.querySetter->parameters[0]), director, query.method())); + void* stateArgs[] = {state.get()}; + Invoke(m.querySetter, row.get(), stateArgs); + } +} + +void AddSliderRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, SliderSetting& setting) +{ + if (!sliderActive) + return; + auto& m = SliderMeta(); + const auto* add = m.context.GetMethodInfo("AddSlider", 9); + Root children(Call(parent, "get_Children")); + const int before = Count(children.get()); + if (before == PageCatalog::NativeChildLimit) + throw std::runtime_error("slider category capacity"); + Root label(reinterpret_cast(il2cpp_string_new(setting.state().id().c_str()))); + Root get(MakeDelegate(il2cpp_class_from_type(add->parameters[2]), director, sliderGetter.method())); + Root set(MakeDelegate(il2cpp_class_from_type(add->parameters[3]), director, sliderSetter.method())); + Root state(MakeDelegate(il2cpp_class_from_type(add->parameters[4]), director, query.method())); + bool whole = false; + float minimum = setting.minimum(), maximum = setting.maximum(); + void* args[] = {parent, label.get(), get.get(), set.get(), state.get(), nullptr, &whole, &minimum, &maximum}; + Invoke(add, context, args); + if (Count(children.get()) != before + 1) + throw std::runtime_error("slider row insertion"); + Root row(Item(children.get(), before)); + if (row.get()->klass != m.row.get_cls() || !HasLabel(row.get(), setting.state().id().c_str())) + throw std::runtime_error("slider row identity"); + // Native SliderOptionLabelType: Value = 0, Percentage = 1. Speed is a raw + // number; fractional controls retain the existing percentage presentation. + int labelType = setting.label() == SliderLabel::Value ? 0 : 1; + void* labelArgs[] = {&labelType}; + Call(row.get(), "set_LabelType", 1, labelArgs); +} + +void AddGeneralHook(auto original, Il2CppObject* director, Il2CppObject* context) +{ + original(director, context); + if (!OnUIThread()) + return; + try { + AddRow(director, context); + } catch (...) { + Warn("confirmation row insertion unavailable"); + } + try { + AddPages(director, context); + } catch (...) { + Warn("settings page insertion unavailable"); + } +} +void RefreshHook(auto original, Il2CppObject* widget) +{ + if (!OnUIThread()) { + original(widget); + return; + } + bool owned = false; + try { + Root context(Invoke(WidgetMeta(widget).getContext, widget)); + owned = OwnsValueContext(context.get()); + auto* view = FindValueWidget(widget); + if (view && (view->rendering || view->binding || view->clearing)) + return; + if (view && Target(view->context) != context.get()) { + Clear(*view); + view = nullptr; + } + if (!owned) { + if (view) + Clear(*view); + } else { + if (!view) + view = &Track(widget, context.get()); + // Observe calls a feature-owned reader. It may synchronously release this + // widget and bind another, so protect the slot before calling Bind too. + struct Binding { + ValueWidget& view; + ValueWidget* previous; + explicit Binding(ValueWidget& view) + : view(view) + , previous(bindingView) + { + view.binding = true; + bindingView = &view; + } + ~Binding() + { + view.binding = false; + bindingView = previous; + } + } binding(*view); + if (!view->preserveNextRefresh) + view->state->Bind(); + if (Target(view->widget) != widget || Target(view->context) != context.get()) { + view->state->Unbind(); + return; + } + view->preserveNextRefresh = false; + Render(*view, original, widget); + return; + } + } catch (const std::exception& error) { + Warn("settings widget refresh unavailable"); + if (owned) { + HideUnsupported(widget); + return; + } + } catch (...) { + if (owned) { + HideUnsupported(widget); + return; + } + Warn("settings widget refresh unavailable"); + } + original(widget); +} +void SelectionTransitionHook(auto original, Il2CppObject* control, int state, bool instant) +{ + original(control, state, instant); + if (!OnUIThread() || !selectionActive) + return; + static auto* toggleClass = il2cpp_class_from_type(SelectionMeta().toggleField->type); + if (!control || control->klass != toggleClass) + return; + for (auto& view : ValueViews()) { + if (Target(view.selectionControl) != control || !view.state) + continue; + view.pressed = state == 2; // Selectable.SelectionState.Pressed, not Selected/focus. + if (view.rendering || view.binding || view.requesting || view.clearing) + return; + try { + Root widget(Target(view.widget)); + Root context(widget.get() ? Invoke(WidgetMeta(widget.get()).getContext, widget.get()) : nullptr); + if (!context.get() || Target(view.context) != context.get() || !OwnsValueContext(context.get())) + return; + struct Scope { + ValueWidget& view; + NativeViewState::RenderScope suppress; + Scope(ValueWidget& view) + : view(view) + , suppress(*view.state) + { view.rendering = true; } + ~Scope() + { view.rendering = false; } + } scope(view); + // On/Off animation may have first published its sprite since the last + // bind. Learn it on input, never through an Update hook or timer. + if (!HasPressedChoiceSprite()) + for (auto& sibling : ValueViews()) + if (auto* background = Target(sibling.choiceBackground)) + RememberChoiceSprite(background); + TryStyleChoice(view); + } catch (...) { + Warn("settings selection presentation unavailable"); + } + return; + } +} +void RefreshViews() +{ + if (!OnUIThread()) + return; + for (auto& view : ValueViews()) { + if (view.requesting || view.rendering || view.binding || view.clearing) + continue; + Root widget(Target(view.widget)); + if (!widget.get()) + continue; + try { + Root context(Invoke(WidgetMeta(widget.get()).getContext, widget.get())); + if (Target(view.context) != context.get() || !OwnsValueContext(context.get())) + continue; + Invoke(WidgetMeta(widget.get()).refresh, widget.get()); + } catch (...) { + HideUnsupported(widget.get()); + } + } + RefreshConditionalSections(); + RefreshPageSummaries(); +} +void ChangeValue(auto original, Il2CppObject* widget, auto desired) +{ + if (!OnUIThread()) { + original(widget, desired); + return; + } + bool owned = false; + try { + Root context(Invoke(WidgetMeta(widget).getContext, widget)); + owned = OwnsValueContext(context.get()); + if (owned) { + auto* view = FindValueWidget(widget); + if (!view || view->rendering || view->binding || view->requesting || view->clearing + || Target(view->context) != context.get()) + return; + { + struct RequestScope { + ValueWidget& view; + explicit RequestScope(ValueWidget* view) + : view(*view) + { view->requesting = true; } + ~RequestScope() + { view.requesting = false; } + } requestScope(view); + auto result = view->state->Request(desired); + if (Target(view->widget) != widget || Target(view->context) != context.get()) + return; + if (result == Outcome::Suppressed || result == Outcome::Busy) + return; + // Refresh through the hook once, preserving the write result. A fresh Bind + // here would erase an unverified outcome merely because a later read works. + view->preserveNextRefresh = true; + Invoke(WidgetMeta(widget).refresh, widget); + } + RefreshConditionalSections(); // The requesting row may now safely be released/rebound. + // Commands can depend on a choice (e.g. Preview is disabled for Off). + // Refresh only their presentation after consuming the write result; + // rebuilding the page here would discard the requesting row's outcome. + RefreshActionPresentations(); + RefreshPageSummaries(); + return; + } + } catch (...) { + if (owned) { + HideUnsupported(widget); + return; + } + Warn("settings value change unavailable"); + } + original(widget, desired); +} +void ChangedHook(auto original, Il2CppObject* widget, bool desired) +{ ChangeValue(original, widget, desired); } +// Core controls may already be active when an optional family's install fails. +// Keep its retained detours native until every hook in that family is ready. +void SelectionRefreshHook(auto original, Il2CppObject* widget) +{ + if (!selectionActive) + return original(widget); + RefreshHook(original, widget); +} +void SelectionChangedHook(auto original, Il2CppObject* widget, bool desired) +{ + if (!selectionActive) + return original(widget, desired); + ChangeValue(original, widget, desired); +} +void SliderRefreshHook(auto original, Il2CppObject* widget) +{ + if (!sliderActive) + return original(widget); + RefreshHook(original, widget); +} +void SliderChangedHook(auto original, Il2CppObject* widget, float desired) +{ + if (!sliderActive) + return original(widget, desired); + ChangeValue(original, widget, desired); +} +void SliderValueLabelHook(auto original, Il2CppObject* widget, float value) +{ + if (OnUIThread() && sliderActive) { + try { + auto* view = FindValueWidget(widget); + Root context(view ? Invoke(SliderMeta().getContext, widget) : nullptr); + if (view && view->state && Target(view->context) == context.get() && OwnsValueContext(context.get()) + && view->state->known()) { + // Unity invokes this label listener separately after the change listener. + // Its raw drag position can otherwise overwrite the refreshed, snapped + // value. Format the applied snapshot; never send this rounded value to + // the slider, the setting owner, or the TOML writer. + value = view->state->displayNumber(); + } + } catch (...) { + Warn("settings slider value label unavailable"); + } + } + original(widget, value); // Keep native text formatting/localization and pooling. +} +void ReleaseHook(auto original, Il2CppObject* widget) +{ + if (OnUIThread()) { + try { + if (auto* view = FindValueWidget(widget)) + Clear(*view); + } catch (...) { + Warn("settings widget release unavailable"); + } + } + original(widget); +} +void Invalidate() +{ + ClearSectionPage(); + InvalidateFleetCommanderConfirmationSession(); + ForbiddenTechConfirmationSetting().InvalidateSession(); + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + setting->state().InvalidateSession(); + for (const auto& page : Pages()) + for (auto* choice : page.Controls()) + choice->state().InvalidateSession(); + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + if (setting != &FleetCommanderConfirmationSetting()) + setting->InvalidateSession(); + for (auto& view : ValueViews()) { + if (!view.state) + continue; + view.state->Invalidate(); + // Re-rendering during a native account transition can read the old account. + // Hide the indicators immediately; next explicit bind may establish readiness. + if (auto* widget = Target(view.widget)) { + try { + Render(view, [](Il2CppObject*) {}, widget); + } catch (...) { + HideUnsupported(widget); + } + } + } +} +void SessionBoundary(auto original, Il2CppObject* owner) +{ + if (OnUIThread()) { + try { + Invalidate(); + } catch (...) { + Warn("settings session invalidation unavailable"); + } + } + original(owner); +} +void ReloadHook(auto original, Il2CppObject* owner) +{ SessionBoundary(original, owner); } +void SessionHook(auto original, Il2CppObject* owner) +{ SessionBoundary(original, owner); } +void LoadHook(auto original, Il2CppObject* owner) +{ SessionBoundary(original, owner); } + +void PrepareValueViews(std::size_t count) +{ ValueViews().resize(std::max(ValueViews().size(), count)); } +bool ValueWidgetsBusy() +{ + for (const auto& view : ValueViews()) + if (view.requesting || view.rendering || view.binding || view.clearing) + return true; + return false; +} +void InstallChoiceAndSliderWidgets() +{ + auto& m = PageMeta(); + if (std::any_of(Pages().begin(), Pages().end(), + [](const auto& page) { return !page.template Controls().empty(); })) { + auto& selection = SelectionMeta(); + const auto* get = selection.director.GetMethodInfo("GetQualityOptionSelectedIndex", 0); + const auto* set = selection.director.GetMethodInfo("OnQualityOptionSelected", 1); + const auto* add = selection.context.GetMethodInfo("AddSelection", 6); + if (!Instance(get, 0, IL2CPP_TYPE_I4)) + throw std::runtime_error("SettingsSectionDirector.GetQualityOptionSelectedIndex: expected instance Int32()"); + if (!Instance(set, 1, IL2CPP_TYPE_VOID) || !Type(set->parameters[0], IL2CPP_TYPE_I4)) + throw std::runtime_error("SettingsSectionDirector.OnQualityOptionSelected: expected instance Void(Int32)"); + if (!Instance(add, 6, IL2CPP_TYPE_VOID) || !Reference(add->parameters[0]) + || !Type(add->parameters[1], IL2CPP_TYPE_STRING) || !Type(add->parameters[2], IL2CPP_TYPE_SZARRAY) + || !Reference(add->parameters[3]) || !Reference(add->parameters[4]) + || !Type(add->parameters[5], IL2CPP_TYPE_STRING)) + throw std::runtime_error("SettingsContext.AddSelection: expected instance Void(reference, String, array, " + "reference, reference, String)"); + if (!Instance(selection.querySetter, 1, IL2CPP_TYPE_VOID) || !Reference(selection.querySetter->parameters[0])) + throw std::runtime_error("SelectionItemOptionContext.set_QueryOptionState: expected instance Void(reference)"); + if (!selection.getContext || !Reference(selection.getContext->return_type) + || !Instance(selection.getContext, 0, selection.getContext->return_type->type)) + throw std::runtime_error("SelectionItemOptionWidget.get_Context: expected instance reference result"); + if (!selectionGetter.Initialize(get, GetSelected)) + throw std::runtime_error("selection getter: incompatible native callback schema"); + if (!selectionSetter.Initialize(set, SetSelected)) + throw std::runtime_error("selection setter: incompatible native callback schema"); + static auto selectable = il2cpp_get_class_helper("UnityEngine.UI", "UnityEngine.UI", "Selectable"); + const auto* transition = selectable.GetMethodInfo("DoStateTransition", 2); + if (!Instance(transition, 2, IL2CPP_TYPE_VOID) || !Type(transition->parameters[0], IL2CPP_TYPE_VALUETYPE) + || !il2cpp_class_is_enum(il2cpp_class_from_type(transition->parameters[0])) + || !Type(il2cpp_class_enum_basetype(il2cpp_class_from_type(transition->parameters[0])), IL2CPP_TYPE_I4) + || !Type(transition->parameters[1], IL2CPP_TYPE_BOOLEAN)) + throw std::runtime_error("Selectable.DoStateTransition: expected instance Void(Int32 enum, Boolean)"); + const std::array targets{selection.refresh, selection.changed, selection.release, transition}; + const std::array names{"SelectionItemOptionWidget.SetWidgetData", "SelectionItemOptionWidget.OnToggleValueChanged", + "SelectionItemOptionWidget.OnAboutToReleaseContext", "Selectable.DoStateTransition"}; + for (std::size_t i = 0; i < targets.size(); ++i) { + if (!Instance(targets[i], i == 3 ? 2 : i == 1 ? 1 : 0, IL2CPP_TYPE_VOID)) + throw std::runtime_error(std::string(names[i]) + ": incompatible instance Void hook signature"); + if (i == 1 && !Type(targets[i]->parameters[0], IL2CPP_TYPE_BOOLEAN)) + throw std::runtime_error(std::string(names[i]) + ": incompatible value parameter"); + for (std::size_t j = 0; j < i; ++j) + if (targets[i]->methodPointer == targets[j]->methodPointer) + throw std::runtime_error(std::string(names[i]) + " shares target with " + names[j]); + const auto& core = ToggleMeta(); + for (auto* existing : {core.refresh, core.changed, core.release, core.addGeneral, core.reload, core.session, + core.load, m.bind, m.release, m.selected, m.destroyed}) + if (targets[i]->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string(names[i]) + " shares target with " + existing->klass->name + "." + + existing->name); + } + for (const auto& page : Pages()) + for (auto* choice : page.Controls()) + if (!choice->state().SetChangeObserver(RefreshViews)) + throw std::runtime_error("selection observer ownership"); + ValidateHookFit(selection.refresh); + if (!SPUD_STATIC_DETOUR(selection.refresh->methodPointer, SelectionRefreshHook)) + throw std::runtime_error("SelectionItemOptionWidget.SetWidgetData: hook installation failed"); + ValidateHookFit(selection.changed); + if (!SPUD_STATIC_DETOUR(selection.changed->methodPointer, SelectionChangedHook)) + throw std::runtime_error("SelectionItemOptionWidget.OnToggleValueChanged: hook installation failed"); + ValidateHookFit(selection.release); + if (!SPUD_STATIC_DETOUR(selection.release->methodPointer, ReleaseHook)) + throw std::runtime_error("SelectionItemOptionWidget.OnAboutToReleaseContext: hook installation failed"); + ValidateHookFit(transition); + if (!SPUD_STATIC_DETOUR(transition->methodPointer, SelectionTransitionHook)) + throw std::runtime_error("Selectable.DoStateTransition: hook installation failed"); + selectionActive = true; + } + if (std::any_of(Pages().begin(), Pages().end(), + [](const auto& page) { return !page.template Controls().empty(); })) { + auto& slider = SliderMeta(); + const auto* get = slider.director.GetMethodInfo("GetCurrentShadowsIndex", 0); + const auto* set = slider.director.GetMethodInfo("OnShadowsSettingChanged", 1); + const auto* add = slider.context.GetMethodInfo("AddSlider", 9); + const auto* labelType = slider.row.GetMethodInfo("set_LabelType", 1); + if (!Instance(get, 0, IL2CPP_TYPE_R4)) + throw std::runtime_error("SettingsSectionDirector.GetCurrentShadowsIndex: expected instance Single()"); + if (!Instance(set, 1, IL2CPP_TYPE_VOID) || !Type(set->parameters[0], IL2CPP_TYPE_R4)) + throw std::runtime_error("SettingsSectionDirector.OnShadowsSettingChanged: expected instance Void(Single)"); + if (!Instance(add, 9, IL2CPP_TYPE_VOID) || !Reference(add->parameters[0]) + || !Type(add->parameters[1], IL2CPP_TYPE_STRING) || !Reference(add->parameters[2]) + || !Reference(add->parameters[3]) || !Reference(add->parameters[4]) + || !Type(add->parameters[5], IL2CPP_TYPE_SZARRAY) || !Type(add->parameters[6], IL2CPP_TYPE_BOOLEAN) + || !Type(add->parameters[7], IL2CPP_TYPE_R4) || !Type(add->parameters[8], IL2CPP_TYPE_R4)) + throw std::runtime_error("SettingsContext.AddSlider: expected instance Void(reference, String, reference, " + "reference, reference, array, Boolean, Single, Single)"); + if (!Instance(labelType, 1, IL2CPP_TYPE_VOID) || !Type(labelType->parameters[0], IL2CPP_TYPE_VALUETYPE) + || !il2cpp_class_is_enum(il2cpp_class_from_type(labelType->parameters[0])) + || !Type(il2cpp_class_enum_basetype(il2cpp_class_from_type(labelType->parameters[0])), IL2CPP_TYPE_I4)) + throw std::runtime_error("SliderOptionContext.set_LabelType: expected instance Void(Int32 enum)"); + if (!slider.getContext || !Reference(slider.getContext->return_type) + || !Instance(slider.getContext, 0, slider.getContext->return_type->type)) + throw std::runtime_error("SliderOptionWidget.get_Context: expected instance reference result"); + if (!sliderGetter.Initialize(get, GetNumber)) + throw std::runtime_error("slider getter: incompatible native callback schema"); + if (!sliderSetter.Initialize(set, SetNumber)) + throw std::runtime_error("slider setter: incompatible native callback schema"); + const std::array targets{slider.refresh, slider.changed, slider.release, slider.valueLabel}; + const std::array names{"SliderOptionWidget.SetWidgetData", "SliderOptionWidget.OnSliderValueChanged", + "SliderOptionWidget.OnAboutToReleaseContext", "SliderOptionWidget.UpdateValueLabel"}; + for (std::size_t i = 0; i < targets.size(); ++i) { + const bool takesValue = i == 1 || i == 3; + if (!Instance(targets[i], takesValue ? 1 : 0, IL2CPP_TYPE_VOID)) + throw std::runtime_error(std::string(names[i]) + ": incompatible instance Void hook signature"); + if (takesValue && !Type(targets[i]->parameters[0], IL2CPP_TYPE_R4)) + throw std::runtime_error(std::string(names[i]) + ": incompatible value parameter"); + for (std::size_t j = 0; j < i; ++j) + if (targets[i]->methodPointer == targets[j]->methodPointer) + throw std::runtime_error(std::string(names[i]) + " shares target with " + names[j]); + const auto& core = ToggleMeta(); + for (auto* existing : {core.refresh, core.changed, core.release, core.addGeneral, core.reload, core.session, + core.load, m.bind, m.release, m.selected, m.destroyed}) + if (targets[i]->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string(names[i]) + " shares target with " + existing->klass->name + "." + + existing->name); + if (selectionActive) + for (auto* existing : {SelectionMeta().refresh, SelectionMeta().changed, SelectionMeta().release}) + if (targets[i]->methodPointer == existing->methodPointer) + throw std::runtime_error(std::string(names[i]) + " shares target with " + existing->klass->name + "." + + existing->name); + } + for (const auto& page : Pages()) + for (auto* setting : page.Controls()) + if (!setting->state().SetChangeObserver(RefreshViews)) + throw std::runtime_error("slider observer ownership"); + ValidateHookFit(slider.refresh); + if (!SPUD_STATIC_DETOUR(slider.refresh->methodPointer, SliderRefreshHook)) + throw std::runtime_error("SliderOptionWidget.SetWidgetData: hook installation failed"); + ValidateHookFit(slider.changed); + if (!SPUD_STATIC_DETOUR(slider.changed->methodPointer, SliderChangedHook)) + throw std::runtime_error("SliderOptionWidget.OnSliderValueChanged: hook installation failed"); + ValidateHookFit(slider.release); + if (!SPUD_STATIC_DETOUR(slider.release->methodPointer, ReleaseHook)) + throw std::runtime_error("SliderOptionWidget.OnAboutToReleaseContext: hook installation failed"); + ValidateHookFit(slider.valueLabel); + if (!SPUD_STATIC_DETOUR(slider.valueLabel->methodPointer, SliderValueLabelHook)) + throw std::runtime_error("SliderOptionWidget.UpdateValueLabel: hook installation failed"); + sliderActive = true; + } +} +bool InstallCoreValueWidgets() +{ + if (active || installing) + return false; + installing = true; + try { + auto& m = ToggleMeta(); + const std::array hooks{m.addGeneral, m.refresh, m.changed, m.release, m.reload, m.session, m.load}; + const std::array names{"SettingsSectionDirector.AddGeneralSettings", + "ToggleOptionWidget.SetWidgetData", + "ToggleOptionWidget.OnToggleValueChanged", + "ToggleOptionWidget.OnAboutToReleaseContext", + "PersistentPrefsManager.RegisterEvents", + "PersistentPrefsManager.GameSessionStartedEventHandler", + "PersistentPrefsManager.LoadPersistentPrefsFromCloud"}; + for (std::size_t i = 0; i < hooks.size(); ++i) { + if (!Instance(hooks[i], i == 0 || i == 2 ? 1 : 0, IL2CPP_TYPE_VOID)) + throw std::runtime_error(std::string(names[i]) + ": expected instance Void hook with " + + std::to_string(i == 0 || i == 2 ? 1 : 0) + " arguments"); + for (std::size_t j = 0; j < i; ++j) + if (hooks[i]->methodPointer == hooks[j]->methodPointer) + throw std::runtime_error(std::string(names[i]) + " shares hook target with " + names[j]); + } + const auto* getSchema = m.director.GetMethodInfo("IsBorgCubeCuttingBeamConfirmationOn", 0); + const auto* setSchema = m.director.GetMethodInfo("ToggleBorgCubeCuttingBeamConfirmation", 1); + const auto* querySchema = m.director.GetMethodInfo("QueryShouldShowGenericPcSetting", 0); + if (!Instance(getSchema, 0, IL2CPP_TYPE_BOOLEAN)) + throw std::runtime_error( + "SettingsSectionDirector.IsBorgCubeCuttingBeamConfirmationOn: expected instance Boolean()"); + if (!Instance(setSchema, 1, IL2CPP_TYPE_VOID) || !Type(setSchema->parameters[0], IL2CPP_TYPE_BOOLEAN)) + throw std::runtime_error( + "SettingsSectionDirector.ToggleBorgCubeCuttingBeamConfirmation: expected instance Void(Boolean)"); + if (!querySchema || !il2cpp_class_is_enum(il2cpp_class_from_type(querySchema->return_type)) + || !Type(il2cpp_class_enum_basetype(il2cpp_class_from_type(querySchema->return_type)), IL2CPP_TYPE_I4) + || !Instance(querySchema, 0, IL2CPP_TYPE_VALUETYPE)) + throw std::runtime_error( + "SettingsSectionDirector.QueryShouldShowGenericPcSetting: expected instance Int32 enum()"); + if (!Instance(m.addToggle, 4, IL2CPP_TYPE_VOID) || !Type(m.addToggle->parameters[1], IL2CPP_TYPE_STRING) + || !Reference(m.addToggle->parameters[0]) || !Reference(m.addToggle->parameters[2]) + || !Reference(m.addToggle->parameters[3])) + throw std::runtime_error("SettingsContext.AddToggle: expected Void(reference, String, reference, reference)"); + if (!Type(m.changed->parameters[0], IL2CPP_TYPE_BOOLEAN)) + throw std::runtime_error("ToggleOptionWidget.OnToggleValueChanged: expected Boolean parameter"); + if (!Reference(m.addGeneral->parameters[0])) + throw std::runtime_error("SettingsSectionDirector.AddGeneralSettings: expected reference parameter"); + if (!m.getContext || !Reference(m.getContext->return_type)) + throw std::runtime_error("ToggleOptionWidget.get_Context: expected reference result"); + if (!Instance(m.querySetter, 1, IL2CPP_TYPE_VOID) || !Reference(m.querySetter->parameters[0])) + throw std::runtime_error("ToggleOptionContext.set_QueryOptionState: expected instance Void(reference)"); + if (!getter.Initialize(getSchema, GetEnabled)) + throw std::runtime_error("FC confirmation getter: incompatible native callback schema"); + if (!setter.Initialize(setSchema, SetEnabled)) + throw std::runtime_error("FC confirmation setter: incompatible native callback schema"); + if (!query.Initialize(querySchema, QueryState)) + throw std::runtime_error("FC confirmation query: incompatible native callback schema"); + uiThread = std::this_thread::get_id(); + if (!FleetCommanderConfirmationSetting().SetChangeObserver(RefreshViews)) + throw std::runtime_error("FleetCommanderConfirmationSetting: observer ownership unavailable"); + if (!ForbiddenTechConfirmationSetting().SetChangeObserver(RefreshViews)) + throw std::runtime_error("ForbiddenTechConfirmationSetting: observer ownership unavailable"); + // A rejected target need not throw. Keep any installed hooks on their native + // path until the complete adapter is ready; do not retry a partial install. + ValidateHookFit(m.refresh); + if (!SPUD_STATIC_DETOUR(m.refresh->methodPointer, RefreshHook)) + throw std::runtime_error("ToggleOptionWidget.SetWidgetData: hook installation failed"); + ValidateHookFit(m.changed); + if (!SPUD_STATIC_DETOUR(m.changed->methodPointer, ChangedHook)) + throw std::runtime_error("ToggleOptionWidget.OnToggleValueChanged: hook installation failed"); + ValidateHookFit(m.release); + if (!SPUD_STATIC_DETOUR(m.release->methodPointer, ReleaseHook)) + throw std::runtime_error("ToggleOptionWidget.OnAboutToReleaseContext: hook installation failed"); + ValidateHookFit(m.reload); + if (!SPUD_STATIC_DETOUR(m.reload->methodPointer, ReloadHook)) + throw std::runtime_error("PersistentPrefsManager.RegisterEvents: hook installation failed"); + ValidateHookFit(m.session); + if (!SPUD_STATIC_DETOUR(m.session->methodPointer, SessionHook)) + throw std::runtime_error("PersistentPrefsManager.GameSessionStartedEventHandler: hook installation failed"); + ValidateHookFit(m.load); + if (!SPUD_STATIC_DETOUR(m.load->methodPointer, LoadHook)) + throw std::runtime_error("PersistentPrefsManager.LoadPersistentPrefsFromCloud: hook installation failed"); + ValidateHookFit(m.addGeneral); + if (!SPUD_STATIC_DETOUR(m.addGeneral->methodPointer, AddGeneralHook)) + throw std::runtime_error("SettingsSectionDirector.AddGeneralSettings: hook installation failed"); + active = true; + return true; + } catch (...) { + Warn("core value widget installation unavailable"); + } + return false; +} + +} // namespace mod_settings::native +#endif diff --git a/mods/src/settings/native/value_widgets.h b/mods/src/settings/native/value_widgets.h new file mode 100644 index 000000000..ab8759914 --- /dev/null +++ b/mods/src/settings/native/value_widgets.h @@ -0,0 +1,68 @@ +#pragma once + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "interop.h" +#include "settings/page_catalog.h" + +namespace mod_settings::native +{ +struct ValueWidgetMetadata { + bool selection, slider; + explicit ValueWidgetMetadata(bool selection = false, bool slider = false) + : selection(selection) + , slider(slider) + { + } + IL2CppClassHelper director = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "SettingsSectionDirector"); + IL2CppClassHelper widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", + slider ? "SliderOptionWidget" + : selection ? "SelectionItemOptionWidget" + : "ToggleOptionWidget"); + IL2CppClassHelper context = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "SettingsContext"); + IL2CppClassHelper row = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", + slider ? "SliderOptionContext" + : selection ? "SelectionItemOptionContext" + : "ToggleOptionContext"); + IL2CppClassHelper prefs = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PersistentPrefs", "PersistentPrefsManager"); + const MethodInfo* addGeneral = director.GetMethodInfo("AddGeneralSettings", 1); + const MethodInfo* addToggle = context.GetMethodInfo("AddToggle", 4); + const MethodInfo* refresh = widget.GetMethodInfo("SetWidgetData", 0); + const MethodInfo* changed = widget.GetMethodInfo(slider ? "OnSliderValueChanged" : "OnToggleValueChanged", 1); + const MethodInfo* valueLabel = slider ? widget.GetMethodInfo("UpdateValueLabel", 1) : nullptr; + const MethodInfo* release = widget.GetMethodInfo("OnAboutToReleaseContext", 0); + const MethodInfo* reload = prefs.GetMethodInfo("RegisterEvents", 0); + const MethodInfo* session = prefs.GetMethodInfo("GameSessionStartedEventHandler", 0); + const MethodInfo* load = prefs.GetMethodInfo("LoadPersistentPrefsFromCloud", 0); + const MethodInfo* getContext = widget.GetMethodInfo("get_Context", 0); + const MethodInfo* querySetter = row.GetMethodInfo("set_QueryOptionState", 1); + FieldInfo* queryField = Field(row.get_cls(), "k__BackingField"); + FieldInfo* labelField = Field(widget.get_cls(), "_label"); + FieldInfo* toggleField = Field(widget.get_cls(), slider ? "_slider" : "_toggle"); + FieldInfo* stateField = Field(widget.get_cls(), slider ? "_valueLabel" : "_toggleStateAnimator"); +}; + +ValueWidgetMetadata& ToggleMeta(); +ValueWidgetMetadata& SelectionMeta(); +ValueWidgetMetadata& SliderMeta(); +ValueWidgetMetadata& WidgetMeta(Il2CppObject* widget); +bool OnUIThread(); +bool SelectionActive(); +bool SliderActive(); +const MethodInfo* QueryMethod(); +BooleanSetting* SettingFor(Il2CppObject* context); +std::pair ChoiceFor(Il2CppObject* context); +SliderSetting* SliderFor(Il2CppObject* context); +bool OwnsValueContext(Il2CppObject* context); +void AddBooleanRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, BooleanSetting& setting); +void AddChoiceRows(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, ChoiceSetting& setting); +void AddSliderRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, SliderSetting& setting); +void PrepareValueViews(std::size_t count); +bool ValueWidgetsBusy(); +void RefreshViews(); +void InstallChoiceAndSliderWidgets(); +bool InstallCoreValueWidgets(); // True only for this first successful installation. +} // namespace mod_settings::native + +#endif diff --git a/mods/src/settings/native_boolean_callback.h b/mods/src/settings/native_boolean_callback.h new file mode 100644 index 000000000..ab899d286 --- /dev/null +++ b/mods/src/settings/native_boolean_callback.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace mod_settings +{ +// Process-lifetime schema, owned by the registration, not a settings page. The +// donor supplies only matching reflection metadata; every executable path is +// replaced. Never modify a MethodInfo owned by IL2CPP. +template class NativeCallback +{ +public: + using Function = Result (*)(Il2CppObject*, Args..., const MethodInfo*); + NativeCallback() = default; + NativeCallback(const NativeCallback&) = delete; + NativeCallback& operator=(const NativeCallback&) = delete; + bool Initialize(const MethodInfo* schema, Function callback) + { + if (initialized_ || !schema || !callback || schema->is_generic || schema->is_inflated + || schema->has_full_generic_sharing_signature || (schema->flags & METHOD_ATTRIBUTE_STATIC) + || (schema->flags & METHOD_ATTRIBUTE_VIRTUAL) || schema->parameters_count != sizeof...(Args)) + return false; + if (!schema->return_type || schema->return_type->byref) + return false; + if constexpr (std::is_void_v) { + if (schema->return_type->type != IL2CPP_TYPE_VOID) + return false; + } else if constexpr (std::is_same_v) { + if (schema->return_type->type != IL2CPP_TYPE_BOOLEAN) + return false; + } else if constexpr (std::is_same_v) { + if (schema->return_type->type != IL2CPP_TYPE_R4) + return false; + } else if constexpr (std::is_same_v) { + if (schema->return_type->type != IL2CPP_TYPE_STRING) + return false; + } else { + static_assert(std::is_same_v, "Only boolean, void, Single and Int32 callbacks are supported"); + // Enum users additionally validate the Int32 backing type in the adapter. + if (schema->return_type->type != IL2CPP_TYPE_VALUETYPE && schema->return_type->type != IL2CPP_TYPE_I4) + return false; + } + static_assert(((std::is_same_v || std::is_same_v || std::is_same_v) && ...), + "Only boolean, Single and Int32 callback arguments are supported"); + constexpr std::array types{(std::is_same_v ? IL2CPP_TYPE_BOOLEAN + : std::is_same_v ? IL2CPP_TYPE_R4 + : IL2CPP_TYPE_I4)...}; + for (std::size_t i = 0; i < sizeof...(Args); ++i) + if (!schema->parameters || !schema->parameters[i] || schema->parameters[i]->byref + || schema->parameters[i]->type != types[i]) + return false; + method_ = *schema; + method_.methodPointer = reinterpret_cast(callback); + method_.virtualMethodPointer = method_.methodPointer; + method_.invoker_method = Invoke; + initialized_ = true; + return true; + } + const MethodInfo* method() const + { return initialized_ ? &method_ : nullptr; } + +private: + template + static void Call(const MethodInfo* method, void* object, void** args, void* result, std::index_sequence) + { + auto callback = reinterpret_cast(method->methodPointer); + if constexpr (std::is_void_v) + callback(static_cast(object), *static_cast(args[I])..., method); + else + *static_cast(result) = + callback(static_cast(object), *static_cast(args[I])..., method); + } + static void Invoke(Il2CppMethodPointer, const MethodInfo* method, void* object, void** args, void* result) + { Call(method, object, args, result, std::index_sequence_for{}); } + MethodInfo method_{}; + bool initialized_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/native_view_state.h b/mods/src/settings/native_view_state.h new file mode 100644 index 000000000..97f054b52 --- /dev/null +++ b/mods/src/settings/native_view_state.h @@ -0,0 +1,145 @@ +#pragma once +#include "boolean_view.h" +#include "choice_setting.h" +#include "slider_setting.h" +#include + +namespace mod_settings +{ +// Both native widgets expose a bool click. Selection rows retain the complete +// integer snapshot so changing between two other choices still makes a row stale. +class NativeViewState +{ +public: + explicit NativeViewState(BooleanSetting& setting) + : boolean_(std::in_place, setting) + { + } + NativeViewState(ChoiceSetting& setting, int index) + : choice_(std::in_place, setting.state()) + , group_(&setting) + , index_(index) + { + } + explicit NativeViewState(SliderSetting& setting) + : slider_(std::in_place, setting.state()) + , sliderSetting_(&setting) + { + } + void Bind() + { + if (boolean_) + boolean_->Bind(); + else if (choice_) + choice_->Bind(); + else + slider_->Bind(); + } + void Unbind() + { + if (boolean_) + boolean_->Unbind(); + else if (choice_) + choice_->Unbind(); + else + slider_->Unbind(); + } + void Invalidate() + { + if (boolean_) + boolean_->Invalidate(); + else if (choice_) + choice_->Invalidate(); + else + slider_->Invalidate(); + } + bool failed() const + { return boolean_ ? boolean_->failed() : choice_ ? choice_->failed() : slider_->failed(); } + bool known() const + { return slider_ ? slider_->value().has_value() : value().has_value(); } + bool enabled() const + { return known() && (!sliderSetting_ || sliderSetting_->enabled()); } + std::string_view disabledReason() const + { return sliderSetting_ ? std::string_view(sliderSetting_->disabledReason()) : std::string_view{}; } + std::string_view unavailableReason() const + { + const auto reason = boolean_ ? boolean_->unavailableReason() + : choice_ ? choice_->unavailableReason() + : slider_->unavailableReason(); + switch (reason) { + case UnavailableReason::OutsideRange: + return "Outside slider range; use Edit"; + case UnavailableReason::InvalidValue: + return "Invalid value; edit TOML"; + default: + return "Reopen to retry"; + } + } + float number() const + { return slider_ ? slider_->value().value_or(sliderSetting_->minimum()) : 0.0f; } + float displayNumber() const + { return sliderSetting_ ? sliderSetting_->DisplayValue(number()) : 0.0f; } + std::optional value() const + { + if (boolean_) + return boolean_->value(); + if (!choice_) + return std::nullopt; + auto selected = choice_->value(); + return selected ? std::optional(*selected == index_) : std::nullopt; + } + int selected() const + { return choice_ ? choice_->value().value_or(-1) : -1; } + Outcome Request(bool desired) + { + if (boolean_) + return boolean_->Request(desired).outcome; + return choice_ ? (desired ? choice_->Request(index_).outcome : Outcome::Unchanged) : Outcome::Rejected; + } + Outcome Request(float desired) + { + if (!slider_ || !enabled() || !std::isfinite(desired) || desired < sliderSetting_->minimum() + || desired > sliderSetting_->maximum()) + return Outcome::Rejected; + return slider_->Request(sliderSetting_->Snap(desired)).outcome; + } + std::string id() const + { return boolean_ ? boolean_->setting().id() : choice_ ? group_->item_id(index_) : slider_->setting().id(); } + std::string label() const + { + return boolean_ ? boolean_->setting().label() : choice_ ? group_->labels().at(index_) : slider_->setting().label(); + } + std::string status_text() const + { + return boolean_ ? boolean_->setting().status_text() + : choice_ ? choice_->setting().status_text() + : slider_->setting().status_text(); + } + class RenderScope + { + public: + explicit RenderScope(NativeViewState& state) + { + if (state.boolean_) + boolean_.emplace(state.boolean_->setting()); + else if (state.choice_) + choice_.emplace(state.choice_->setting()); + else + slider_.emplace(state.slider_->setting()); + } + + private: + std::optional boolean_; + std::optional::RenderScope> choice_; + std::optional::RenderScope> slider_; + }; + +private: + std::optional boolean_; + std::optional> choice_; + std::optional> slider_; + SliderSetting* sliderSetting_ = nullptr; + ChoiceSetting* group_ = nullptr; + int index_ = 0; +}; +} // namespace mod_settings diff --git a/mods/src/settings/page_catalog.h b/mods/src/settings/page_catalog.h new file mode 100644 index 000000000..d4d6dd572 --- /dev/null +++ b/mods/src/settings/page_catalog.h @@ -0,0 +1,283 @@ +#pragma once + +#include "action_setting.h" +#include "boolean_settings.h" +#include "choice_setting.h" +#include "slider_setting.h" +#include +#include +#include +#include +#include +#include +#include + +namespace mod_settings +{ +// Presentation only. Settings keep their identity, live state and persistence. +class PageCatalog +{ +public: + // Shared with the native adapter's child-list sanity check. Presentation + // producers must fit this budget without limiting the underlying config. + static constexpr int NativeChildLimit = 128; + struct Heading { + std::string id, label; + bool collapsible = false; + // Optional presentation dependency, including the heading and all following + // controls up to the next heading. It never changes their saved values. + std::function visible; + std::function summary; + }; + using Item = std::variant; + struct Page { + std::string id, label, parent; + std::vector items; // Registration order is visual order, including headings. + std::map, std::less<>> itemVisibility; + // Page departure owns draft/capture cancellation, never a pooled row release. + std::function leave; + std::function summary; + const SettingMetadata* metadata = nullptr; + bool HasConditionalSections() const + { + if (!itemVisibility.empty()) + return true; + return std::any_of(items.begin(), items.end(), [](const Item& item) { + const auto* heading = std::get_if(&item); + return heading && static_cast(heading->visible); + }); + } + bool IsVisible(std::string_view id) const + { + if (const auto found = itemVisibility.find(id); found != itemVisibility.end() && !found->second()) + return false; + const Heading* section = nullptr; + for (const auto& item : items) { + if (const auto* heading = std::get_if(&item)) + section = heading; + if (Id(item) == id) + return !section || !section->visible || section->visible(); + } + return true; + } + std::size_t PositionFor(std::string_view id) const + { + for (std::size_t i = 0; i < items.size(); ++i) + if (Id(items[i]) == id) + return i; + return items.size(); + } + // A heading owns following controls up to the next heading. Collapse is + // presentation state only; this lookup never reads or writes a setting. + const Heading* SectionFor(std::string_view setting_id) const + { + const Heading* section = nullptr; + for (const auto& item : items) { + if (const auto* heading = std::get_if(&item)) + section = heading->collapsible ? heading : nullptr; + else if (Id(item) == setting_id) + return section; + } + return nullptr; + } + template auto Controls() const + { + return items | std::views::filter([](const Item& item) { return std::holds_alternative(item); }) + | std::views::transform([](const Item& item) { return std::get(item); }); + } + std::size_t ControlRows() const + { + std::size_t count = 0; + for (const auto& item : items) + std::visit( + [&](const auto& value) { + using T = std::decay_t; + if constexpr (std::is_same_v) + count += value->labels().size(); + else if constexpr (!std::is_same_v) + ++count; + }, + item); + return count; + } + }; + + explicit PageCatalog(std::string root_id, std::string root_label) + { + if (root_id.empty() || root_label.empty()) + throw std::invalid_argument("settings root identity"); + pages_.push_back({std::move(root_id), std::move(root_label), {}, {}}); + } + PageCatalog(const PageCatalog&) = delete; + PageCatalog& operator=(const PageCatalog&) = delete; + const std::vector& RegisteredPages() const + { + CheckThread(); + return pages_; + } + + Registration AddPage(std::string id, std::string label, std::string_view parent) + { + CheckThread(); + if (frozen_) + return Registration::Frozen; + if (id.empty() || label.empty() || !FindPage(parent)) + return Registration::Invalid; + if (FindPage(id)) + return Registration::Duplicate; + for (const auto& page : pages_) + for (const auto& item : page.items) + if (const auto* heading = std::get_if(&item); heading && heading->collapsible && heading->id == id) + return Registration::Invalid; + // Parents already exist: no orphan or cyclic registrations. + pages_.push_back({std::move(id), std::move(label), std::string(parent), {}}); + return Registration::Added; + } + Registration AddBoolean(std::string_view page, BooleanSetting& setting, std::function visible = {}) + { return AddControl(page, setting, std::move(visible)); } + Registration OnLeave(std::string_view id, std::function callback) + { + CheckThread(); + if (frozen_) + return Registration::Frozen; + auto* page = FindPage(id); + if (!page) + return Registration::Invalid; + page->leave = std::move(callback); + return Registration::Added; + } + Registration SetSummary(std::string_view id, std::function callback) + { + CheckThread(); + if (frozen_) + return Registration::Frozen; + auto* page = FindPage(id); + if (!page) + return Registration::Invalid; + page->summary = std::move(callback); + return Registration::Added; + } + Registration SetMetadata(std::string_view id, const SettingMetadata& metadata) + { + CheckThread(); + if (frozen_) + return Registration::Frozen; + auto* page = FindPage(id); + if (!page) + return Registration::Invalid; + page->metadata = &metadata; + return Registration::Added; + } + Registration AddChoice(std::string_view page, ChoiceSetting& setting) + { return AddControl(page, setting); } + Registration AddSlider(std::string_view page, SliderSetting& setting) + { return AddControl(page, setting); } + Registration AddAction(std::string_view page, ActionSetting& action) + { return AddControl(page, action); } + Registration AddHeading(std::string_view page_id, std::string id, std::string label, bool collapsible = false, + std::function visible = {}, std::function summary = {}) + { + CheckThread(); + if (frozen_) + return Registration::Frozen; + auto* page = FindPage(page_id); + if (!page || id.empty() || label.empty()) + return Registration::Invalid; + if (collapsible && FindPage(id)) + return Registration::Invalid; // Both use native category contexts; identities must be distinct. + for (const auto& existing : pages_) + for (const auto& item : existing.items) + if (Id(item) == id) + return Registration::Duplicate; + page->items.emplace_back( + Heading{std::move(id), std::move(label), collapsible, std::move(visible), std::move(summary)}); + return Registration::Added; + } + + // Freeze one immutable plan. Each native settings context gets fresh objects. + // Heading-only pages are empty; building never reads or writes a setting. + std::vector Build() + { + CheckThread(); + frozen_ = true; + auto result = pages_; + for (std::size_t i = result.size(); i-- > 0;) { + if (result[i].ControlRows()) + continue; + const bool has_child = std::any_of(result.begin() + i + 1, result.end(), + [&](const Page& page) { return page.parent == result[i].id; }); + if (!has_child) + result.erase(result.begin() + i); + } + return result; + } + +private: + static const std::string& Id(const Item& item) + { + return std::visit( + [](const auto& value) -> const std::string& { + using T = std::decay_t; + if constexpr (std::is_same_v) + return value.id; + else if constexpr (std::is_same_v || std::is_same_v) + return value->id(); + else + return value->state().id(); + }, + item); + } + template + Registration AddControl(std::string_view page_id, T& setting, std::function visible = {}) + { + CheckThread(); + if (frozen_) + return Registration::Frozen; + auto* page = FindPage(page_id); + if (!page) + return Registration::Invalid; + const auto& state = [&]() -> const auto& { + if constexpr (std::is_same_v || std::is_same_v) + return setting; + else + return setting.state(); + }(); + const auto& label = [&]() -> const std::string& { + if constexpr (std::is_same_v) + return state.label; + else + return state.label(); + }(); + if (state.id().empty() || label.empty()) + return Registration::Invalid; + const Item candidate = &setting; + for (const auto& existing : pages_) + for (const auto& item : existing.items) { + if (Id(item) != Id(candidate)) + continue; + auto* owner = std::get_if(&item); + if (!owner || *owner != &setting) + return Registration::Invalid; + if (existing.id == page_id) + return Registration::Duplicate; + } + page->items.push_back(candidate); + if (visible) + page->itemVisibility.emplace(state.id(), std::move(visible)); + return Registration::Added; + } + Page* FindPage(std::string_view id) + { + auto found = std::find_if(pages_.begin(), pages_.end(), [&](const Page& page) { return page.id == id; }); + return found == pages_.end() ? nullptr : &*found; + } + void CheckThread() const + { + if (std::this_thread::get_id() != thread_) + throw std::logic_error("settings catalog thread mismatch"); + } + const std::thread::id thread_ = std::this_thread::get_id(); + bool frozen_ = false; + std::vector pages_; +}; +} // namespace mod_settings diff --git a/mods/src/settings/page_sections.h b/mods/src/settings/page_sections.h new file mode 100644 index 000000000..2b6adf35b --- /dev/null +++ b/mods/src/settings/page_sections.h @@ -0,0 +1,67 @@ +#pragma once + +#include "page_catalog.h" +#include + +namespace mod_settings +{ +// Visit-local presentation shared by every settings page. Native widget and +// context lifetimes remain with the native adapter. +class PageSections +{ +public: + class RefreshScope + { + public: + explicit RefreshScope(PageSections& owner) + : owner_(owner) + , previous_(std::exchange(owner.refreshing_, true)) + { + } + ~RefreshScope() + { owner_.refreshing_ = previous_; } + RefreshScope(const RefreshScope&) = delete; + RefreshScope& operator=(const RefreshScope&) = delete; + + private: + PageSections& owner_; + bool previous_; + }; + + void Begin(const PageCatalog::Page& page) + { + ExpandAll(); + for (const auto& item : page.items) + if (const auto* heading = std::get_if(&item); heading && heading->collapsible) + collapsed_.push_back(heading->id); + } + void ExpandAll() + { collapsed_.clear(); } + auto Snapshot() const + { return collapsed_; } + void Restore(std::vector collapsed) + { collapsed_ = std::move(collapsed); } + bool Refreshing() const + { return refreshing_; } + bool Collapsed(const PageCatalog::Heading& heading) const + { return std::find(collapsed_.begin(), collapsed_.end(), heading.id) != collapsed_.end(); } + void Toggle(const PageCatalog::Heading& heading) + { + if (!heading.collapsible) + return; + if (Collapsed(heading)) + std::erase(collapsed_, heading.id); + else + collapsed_.push_back(heading.id); + } + bool Visible(const PageCatalog::Page& page, std::string_view id) const + { + const auto* section = page.SectionFor(id); + return page.IsVisible(id) && (!section || !Collapsed(*section)); + } + +private: + std::vector collapsed_; + bool refreshing_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/persisted_setting.h b/mods/src/settings/persisted_setting.h new file mode 100644 index 000000000..6dca5622c --- /dev/null +++ b/mods/src/settings/persisted_setting.h @@ -0,0 +1,50 @@ +#pragma once + +#include "patches/runtime_config.h" +#include "value_settings.h" +#include +#include +#include + +namespace mod_settings +{ +// Persistence is attached to the same definition used by rendering, search, +// defaults and application. No second list of permitted save keys is required. +template + requires std::invocable +ValueDefinition PersistedDefinition(ValueDefinition definition, SettingMetadata metadata, T default_value, + Serializer serialize, std::chrono::milliseconds delay = {}) +{ + if (!metadata.persisted()) + throw std::invalid_argument("persisted setting requires a section and key"); + runtime_config::RegisterSetting(metadata.section.c_str(), metadata.key.c_str()); + definition.metadata = std::move(metadata); + definition.default_value = std::move(default_value); + const auto section = definition.metadata.section; + const auto key = definition.metadata.key; + definition.persist = [section, key, serialize = std::move(serialize), delay](const T& value) { + runtime_config::SaveSetting(section.c_str(), key.c_str(), serialize(value), delay); + }; + definition.save_status = [section, key] { return runtime_config::GetSaveStatus(section.c_str(), key.c_str()); }; + return definition; +} + +template +ValueDefinition PersistedDefinition(ValueDefinition definition, SettingMetadata metadata, T default_value, + std::chrono::milliseconds delay = {}) +{ + return PersistedDefinition( + std::move(definition), std::move(metadata), std::move(default_value), + [](const T& value) -> config_edit::Value { + if constexpr (std::is_same_v || std::is_same_v) + return value; + else if constexpr (std::is_integral_v) + return static_cast(value); + else if constexpr (std::is_floating_point_v) + return static_cast(value); + else + static_assert(std::is_same_v, "setting requires an explicit serializer"); + }, + delay); +} +} // namespace mod_settings diff --git a/mods/src/settings/setting_metadata.h b/mods/src/settings/setting_metadata.h new file mode 100644 index 000000000..c0169e34a --- /dev/null +++ b/mods/src/settings/setting_metadata.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +namespace mod_settings +{ +enum class ApplyTiming { Live, ReopenView, Restart }; +enum class SaveState { None, Pending, Saved, Conflict, Failed, Unavailable }; +struct SaveStatus { + SaveState state = SaveState::None; + std::uint64_t revision = 0; + bool operator==(const SaveStatus&) const = default; +}; +struct SettingMetadata { + std::string section, key, description, unit; + ApplyTiming timing = ApplyTiming::Live; + bool persisted() const + { return !section.empty() && !key.empty(); } + std::string TomlKey() const + { return persisted() ? section + "." + key : std::string{}; } +}; +// A confirmation captures its comparison state before opening. A page reset +// validates every plan before applying any of them. +struct PreparedSettingAction { + std::function current, apply; +}; + +inline const char* SaveStatusText(SaveStatus status) +{ + switch (status.state) { + case SaveState::Pending: + return "Saving..."; + case SaveState::Saved: + return "Saved"; + case SaveState::Conflict: + return "Applied; file changed externally"; + case SaveState::Failed: + return "Applied; saving failed"; + case SaveState::Unavailable: + return "Applied for this session; saving unavailable"; + default: + return ""; + } +} +} // namespace mod_settings diff --git a/mods/src/settings/settings_search.h b/mods/src/settings/settings_search.h new file mode 100644 index 000000000..a3e474108 --- /dev/null +++ b/mods/src/settings/settings_search.h @@ -0,0 +1,118 @@ +#pragma once +#include "page_catalog.h" +#include +#include + +namespace mod_settings +{ +inline std::string SearchWords(std::string_view text) +{ + std::string result; + for (unsigned char c : text) + // TOML underscores, dots and UI spaces should be interchangeable. Preserve + // UTF-8 bytes; current catalog labels/keys use ASCII for searchable words. + result += c == '_' || c == '.' || c == '-' ? ' ' : static_cast(std::tolower(c)); + return result; +} +struct SettingsSearchEntry { + std::string page, item, label, location, key, words; +}; +class SettingsSearch +{ +public: + static constexpr std::size_t ResultLimit = 32; + static constexpr int QueryLimit = 128; + void Build(const std::vector& pages) + { + entries_.clear(); + const auto path = [&](const PageCatalog::Page& page) { + std::string text = page.label; + auto parent = page.parent; + for (std::size_t depth = 0; depth < pages.size() && parent != "community_mod.settings"; ++depth) { + auto it = std::ranges::find(pages, parent, &PageCatalog::Page::id); + if (it == pages.end()) + break; + text = it->label + " > " + text; + parent = it->parent; + } + return text; + }; + for (const auto& page : pages) { + const auto location = path(page); + // Action-only editors expose their one model explicitly. + if (page.metadata) { + Add(page.id, {}, page.label, location, page.metadata->TomlKey(), page.metadata->description); + continue; + } + std::vector seen; + std::string section; + for (const auto& item : page.items) + std::visit( + [&](const auto& value) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + section = value.label; + } else { + const auto& setting = [&]() -> const auto& { + if constexpr (std::is_same_v || std::is_same_v) + return *value; + else + return value->state(); + }(); + const SettingMetadata* metadata = nullptr; + std::string label; + if constexpr (std::is_same_v) { + metadata = setting.metadata; + label = setting.label; + if (!metadata) + return; + } else { + metadata = &setting.metadata(); + label = setting.label(); + } + const auto key = metadata->TomlKey(); + if (!key.empty()) { + if (std::ranges::find(seen, key) != seen.end()) + return; + seen.push_back(key); + } + Add(page.id, setting.id(), label, location + (section.empty() ? "" : " > " + section), key, + metadata->description); + } + }, + item); + } + } + std::vector Find(std::string_view query) const + { + std::vector tokens; + std::istringstream input(SearchWords(query.substr(0, QueryLimit))); + for (std::string token; input >> token;) + tokens.push_back(std::move(token)); + std::vector result; + if (tokens.empty()) + return result; + for (const auto& entry : entries_) + if (std::ranges::all_of(tokens, [&](const auto& token) { return entry.words.find(token) != std::string::npos; })) + result.push_back(&entry); + // Exact key/name matches precede broad matches while registration order + // remains stable within each group. + const auto exact = SearchWords(query); + std::stable_partition(result.begin(), result.end(), [&](auto* entry) { + const auto key = entry->key.substr(entry->key.find('.') + 1); + return SearchWords(entry->key) == exact || SearchWords(key) == exact || SearchWords(entry->label) == exact; + }); + return result; + } + +private: + void Add(std::string page, std::string item, std::string label, std::string location, std::string key, + std::string_view description = {}) + { + auto words = SearchWords(label + " " + key + " " + location + " " + std::string(description)); + entries_.push_back( + {std::move(page), std::move(item), std::move(label), std::move(location), std::move(key), std::move(words)}); + } + std::vector entries_; +}; +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_capture.h b/mods/src/settings/shortcut_capture.h new file mode 100644 index 000000000..dd02ec777 --- /dev/null +++ b/mods/src/settings/shortcut_capture.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include +#include + +namespace mod_settings +{ +// Preserve the modifier families understood by MapKey. Command and Windows +// have distinct Unity keycodes; if a Command press is also reported as Windows, +// prefer Command so the binding does not require the duplicate event later. +template std::string CaptureModifierPrefix(Held held) +{ + std::string token; + if (held(KeyCode::LeftControl) || held(KeyCode::RightControl)) + token += "CTRL-"; + if (held(KeyCode::LeftAlt) || held(KeyCode::RightAlt)) + token += "ALT-"; + if (held(KeyCode::LeftShift) || held(KeyCode::RightShift)) + token += "SHIFT-"; + if (held(KeyCode::LeftCommand) || held(KeyCode::RightCommand)) + token += "CMD-"; + else if (held(KeyCode::LeftWindows) || held(KeyCode::RightWindows)) + token += "WIN-"; + return token; +} + +// Pure input ownership state. The runtime samples supported physical keys only +// while active. A click or hold that opened capture cannot become its result. +class ShortcutCapture +{ +public: + using Keys = std::array(KeyCode::Max)>; + enum class Phase { Idle, ReleaseOpeningKeys, Listening, ReleaseCapturedKeys }; + void Begin() + { phase_ = Phase::ReleaseOpeningKeys; } + void Cancel() + { + if (phase_ != Phase::Idle) + phase_ = Phase::ReleaseCapturedKeys; + } + bool active() const + { return phase_ != Phase::Idle; } + bool listening() const + { return phase_ == Phase::Listening; } + Phase phase() const + { return phase_; } + // Escape/focus loss cancel a pending recording and still drain the held keys. + template + std::optional Tick(const Keys& held, const Keys& down, bool focused, Modifier modifier) + { + if (!active()) + return {}; + if (!focused || down[static_cast(KeyCode::Escape)]) + Cancel(); + bool any = false; + for (bool value : held) + any |= value; + if (phase_ == Phase::ReleaseOpeningKeys || phase_ == Phase::ReleaseCapturedKeys) { + // Unity may report no held keys while unfocused. Require a focused sample + // before releasing ownership, including when Alt-Tab cancelled recording. + if (focused && !any) + phase_ = phase_ == Phase::ReleaseOpeningKeys ? Phase::Listening : Phase::Idle; + return {}; + } + std::optional candidate; + for (std::size_t i = 1; i < down.size(); ++i) { + const auto key = static_cast(i); + if (!down[i] || modifier(key)) + continue; + if (candidate) { + Cancel(); + return {}; + } // Ambiguous simultaneous primary keys. + candidate = key; + } + if (candidate) + phase_ = Phase::ReleaseCapturedKeys; + return candidate; + } + +private: + Phase phase_ = Phase::Idle; +}; +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_catalog.h b/mods/src/settings/shortcut_catalog.h new file mode 100644 index 000000000..cad3bfd69 --- /dev/null +++ b/mods/src/settings/shortcut_catalog.h @@ -0,0 +1,262 @@ +#pragma once +#include "page_catalog.h" +#include "patches/gamefunctions.h" +#include +#include +#include +#include +#include +#include +#include + +namespace mod_settings +{ +// Change + Remove per binding, Add, status/Undo, Restore, optional explanation +// and save notice. Retain the established binding limit with three spare slots. +// Oversized player-authored lists stay live; the editor presents their prefix. +inline constexpr std::size_t ShortcutFixedRowLimit = 8; +inline constexpr std::size_t ShortcutBindingDisplayLimit = (PageCatalog::NativeChildLimit - ShortcutFixedRowLimit) / 2; +constexpr std::size_t VisibleShortcutBindingCount(std::size_t count) +{ return std::min(count, ShortcutBindingDisplayLimit); } +constexpr bool ShortcutCountFitsEdit(std::size_t before, std::size_t after) +{ return after <= ShortcutBindingDisplayLimit || after <= before; } +enum class ShortcutGroup { + Screens, + Previews, + Interface, + Fleet, + Travel, + Camera, + Chat, + Client, + Uncategorized, + Diagnostics +}; +struct ShortcutGroupInfo { + ShortcutGroup group; + std::string_view id, label; +}; +// Category registration is display order: alphabetized, with Diagnostics last. +inline constexpr auto ShortcutGroups = std::to_array({ + {ShortcutGroup::Camera, "camera", "Camera"}, + {ShortcutGroup::Chat, "chat", "Chat"}, + {ShortcutGroup::Client, "client", "Client"}, + {ShortcutGroup::Fleet, "fleet", "Fleet Controls"}, + {ShortcutGroup::Screens, "screens", "Game Screens"}, + {ShortcutGroup::Interface, "interface", "Interface Controls"}, + {ShortcutGroup::Travel, "travel", "Map & Travel"}, + {ShortcutGroup::Previews, "previews", "Previews & Cargo"}, + {ShortcutGroup::Uncategorized, "uncategorized", "Uncategorized"}, + {ShortcutGroup::Diagnostics, "diagnostics", "Diagnostics"}, +}); +struct ShortcutInfo { + GameFunction action; + ShortcutGroup group; + std::string_view label; +}; +constexpr std::string_view ShortcutExplanation(GameFunction action) +{ + switch (action) { + case MoveUp: + case MoveDown: + return "Pan the system or galaxy map. Disabled when keyboard movement is turned off."; + case MoveLeft: + case MoveRight: + return "Pan the map, rotate the station exterior, or change selection in supported screens."; + case Quit: + return "Force closes the client; allows up to 0.5s for pending saves."; + case NativeShortcutGalaxy: + case NativeShortcutEvents: + return "Uses the game's own shortcut behavior for this screen."; + default: + return {}; + } +} +// Optional presentation overrides. Missing actions use their registered config +// key for a fallback label in Uncategorized. Config/defaults/dispatch/save +// identities remain owned by MapKey/config; opening a screen is a UI action. +inline constexpr auto ShortcutCatalog = std::to_array({ + {MoveLeft, ShortcutGroup::Camera, "Pan left"}, + {MoveRight, ShortcutGroup::Camera, "Pan right"}, + {MoveUp, ShortcutGroup::Camera, "Pan up"}, + {MoveDown, ShortcutGroup::Camera, "Pan down"}, + {SelectChatAlliance, ShortcutGroup::Chat, "Switch to alliance chat"}, + {SelectChatGlobal, ShortcutGroup::Chat, "Switch to global chat"}, + {SelectChatPrivate, ShortcutGroup::Chat, "Switch to private chat"}, + {SelectShip1, ShortcutGroup::Fleet, "Select ship 1"}, + {SelectShip2, ShortcutGroup::Fleet, "Select ship 2"}, + {SelectShip3, ShortcutGroup::Fleet, "Select ship 3"}, + {SelectShip4, ShortcutGroup::Fleet, "Select ship 4"}, + {SelectShip5, ShortcutGroup::Fleet, "Select ship 5"}, + {SelectShip6, ShortcutGroup::Fleet, "Select ship 6"}, + {SelectShip7, ShortcutGroup::Fleet, "Select ship 7"}, + {SelectShip8, ShortcutGroup::Fleet, "Select ship 8"}, + {SelectCurrent, ShortcutGroup::Fleet, "Locate selected ship"}, + {ShowAlliance, ShortcutGroup::Screens, "Open alliance"}, + {ShowAllianceArmada, ShortcutGroup::Screens, "Open alliance armadas"}, + {ShowAllianceHelp, ShortcutGroup::Screens, "Open alliance help"}, + {ShowArtifacts, ShortcutGroup::Screens, "Open artifacts"}, + {ShowOfficers, ShortcutGroup::Screens, "Open officers"}, + {ShowOfficerPresets, ShortcutGroup::Screens, "Open officer presets"}, + {ShowOfficerSwap, ShortcutGroup::Fleet, "Swap officers"}, + {ShowShipSwap, ShortcutGroup::Fleet, "Swap ship"}, + {ShowHaven, ShortcutGroup::Screens, "Open Haven"}, + {ShowCommander, ShortcutGroup::Screens, "Open Fleet Commanders"}, + {ShowRefinery, ShortcutGroup::Screens, "Open refinery"}, + {ShowQTrials, ShortcutGroup::Screens, "Open Q's Trials"}, + {ShowBookmarks, ShortcutGroup::Travel, "Open bookmarks"}, + {ShowLookup, ShortcutGroup::Travel, "Find coordinates"}, + {ShowExoComp, ShortcutGroup::Screens, "Open Exocomps"}, + {ShowFactions, ShortcutGroup::Screens, "Open factions"}, + {ShowGifts, ShortcutGroup::Screens, "Open gifts"}, + {ShowDaily, ShortcutGroup::Screens, "Open daily goals"}, + {ShowAwayTeam, ShortcutGroup::Screens, "Open Away Teams"}, + {ShowMissions, ShortcutGroup::Screens, "Open missions"}, + {ShowResearch, ShortcutGroup::Screens, "Open research"}, + {ShowScrapYard, ShortcutGroup::Screens, "Open scrapyard"}, + {ShowShips, ShortcutGroup::Screens, "Manage selected ship"}, + {ShowInventory, ShortcutGroup::Screens, "Open inventory"}, + {ShowStationInterior, ShortcutGroup::Travel, "View station interior"}, + {ShoWStationExterior, ShortcutGroup::Travel, "View station exterior"}, + {ShowGalaxy, ShortcutGroup::Travel, "View galaxy"}, + {NativeShortcutGalaxy, ShortcutGroup::Travel, "View galaxy (native shortcut)"}, + {ShowSystem, ShortcutGroup::Travel, "View system"}, + {ShowChat, ShortcutGroup::Chat, "Open chat"}, + {ShowChatSide1, ShortcutGroup::Chat, "Open side chat 1"}, + {ShowChatSide2, ShortcutGroup::Chat, "Open side chat 2"}, + {ShowEvents, ShortcutGroup::Screens, "Open events"}, + {NativeShortcutEvents, ShortcutGroup::Screens, "Open events (native shortcut)"}, + {ShowSettings, ShortcutGroup::Screens, "Open settings"}, + {ToggleShortcutHints, ShortcutGroup::Interface, "Toggle shortcut hints"}, + {ZoomPreset1, ShortcutGroup::Camera, "Use zoom preset 1"}, + {ZoomPreset2, ShortcutGroup::Camera, "Use zoom preset 2"}, + {ZoomPreset3, ShortcutGroup::Camera, "Use zoom preset 3"}, + {ZoomPreset4, ShortcutGroup::Camera, "Use zoom preset 4"}, + {ZoomPreset5, ShortcutGroup::Camera, "Use zoom preset 5"}, + {ZoomIn, ShortcutGroup::Camera, "Zoom in"}, + {ZoomOut, ShortcutGroup::Camera, "Zoom out"}, + {ZoomMin, ShortcutGroup::Camera, "Zoom to minimum"}, + {ZoomMax, ShortcutGroup::Camera, "Zoom to maximum"}, + {ZoomReset, ShortcutGroup::Camera, "Reset zoom"}, + {UiScaleUp, ShortcutGroup::Interface, "Increase interface size"}, + {UiScaleDown, ShortcutGroup::Interface, "Decrease interface size"}, + {UiShipScaleUp, ShortcutGroup::Interface, "Increase ship panel size"}, + {UiShipScaleDown, ShortcutGroup::Interface, "Decrease ship panel size"}, + {UiViewerScaleUp, ShortcutGroup::Interface, "Increase viewer size"}, + {UiViewerScaleDown, ShortcutGroup::Interface, "Decrease viewer size"}, + {ActionPrimary, ShortcutGroup::Fleet, "Primary action"}, + {ActionSecondary, ShortcutGroup::Fleet, "Secondary action"}, + {ActionQueue, ShortcutGroup::Fleet, "Queue action"}, + {ActionQueueClear, ShortcutGroup::Fleet, "Clear action queue"}, + {ActionView, ShortcutGroup::Fleet, "View target details"}, + {ActionRecall, ShortcutGroup::Fleet, "Recall selected ship"}, + {ActionRecallCancel, ShortcutGroup::Fleet, "Cancel recall"}, + {ActionRepair, ShortcutGroup::Fleet, "Repair selected ship"}, + {SetZoomPreset1, ShortcutGroup::Camera, "Save zoom preset 1"}, + {SetZoomPreset2, ShortcutGroup::Camera, "Save zoom preset 2"}, + {SetZoomPreset3, ShortcutGroup::Camera, "Save zoom preset 3"}, + {SetZoomPreset4, ShortcutGroup::Camera, "Save zoom preset 4"}, + {SetZoomPreset5, ShortcutGroup::Camera, "Save zoom preset 5"}, + {SetZoomDefault, ShortcutGroup::Camera, "Save default zoom"}, + {DisableHotKeys, ShortcutGroup::Client, "Disable mod shortcuts"}, + {EnableHotKeys, ShortcutGroup::Client, "Enable mod shortcuts"}, + {ToggleQueue, ShortcutGroup::Fleet, "Toggle action queue"}, + {ToggleAutoConfirmInstantWarp, ShortcutGroup::Travel, "Cycle instant warp mode"}, + {TogglePreviewLocate, ShortcutGroup::Previews, "Toggle Locate on previews"}, + {TogglePreviewRecall, ShortcutGroup::Previews, "Toggle Recall on previews"}, + {ToggleCargoDefault, ShortcutGroup::Previews, "Toggle automatic cargo previews"}, + {ToggleCargoPlayer, ShortcutGroup::Previews, "Toggle player cargo previews"}, + {ToggleCargoStation, ShortcutGroup::Previews, "Toggle station cargo previews"}, + {ToggleCargoHostile, ShortcutGroup::Previews, "Toggle hostile cargo previews"}, + {ToggleCargoArmada, ShortcutGroup::Previews, "Toggle armada cargo previews"}, + {LogLevelDebug, ShortcutGroup::Diagnostics, "Set logging to Debug"}, + {LogLevelInfo, ShortcutGroup::Diagnostics, "Set logging to Info"}, + {LogLevelTrace, ShortcutGroup::Diagnostics, "Set logging to Trace"}, + {LogLevelError, ShortcutGroup::Diagnostics, "Set logging to Error"}, + {LogLevelWarn, ShortcutGroup::Diagnostics, "Set logging to Warning"}, + {LogLevelOff, ShortcutGroup::Diagnostics, "Turn logging off"}, + {Restart, ShortcutGroup::Client, "Clear localization cache and reload"}, + {Quit, ShortcutGroup::Client, "Force close client"}, + {FocusSearch, ShortcutGroup::Interface, "Focus search"}, + + {ShowShipConstruction, ShortcutGroup::Screens, "Open ship construction"}, + {ShowShields, ShortcutGroup::Screens, "Open shields"}, + {ShowBattlelogs, ShortcutGroup::Screens, "Open battle logs"}, +}); +constexpr bool ValidShortcutCatalog(std::span catalog) +{ + std::array seen{}; + for (const auto& info : catalog) { + if (info.action < 0 || info.action >= GameFunction::Max || info.label.empty() || seen[info.action]) + return false; + bool groupFound = false; + for (const auto& group : ShortcutGroups) + groupFound |= group.group == info.group; + if (!groupFound) + return false; + seen[info.action] = true; + } + return true; +} +static_assert(ValidShortcutCatalog(ShortcutCatalog), "Shortcut overrides need unique valid actions, labels and groups"); + +inline std::string HumanizeShortcutKey(std::string_view key) +{ + std::string label; + bool space = false; + for (char c : key) { + if (c == '_' || c == '-' || c == ' ') { + space = !label.empty(); + continue; + } + if (space) + label += ' '; + space = false; + label += label.empty() && c >= 'a' && c <= 'z' ? static_cast(c - ('a' - 'A')) : c; + } + return label.empty() ? "Shortcut" : label; +} + +// Own generated text: descriptions survive sorting and never borrow temporary +// labels. The same resolver names editors, summaries and overlap warnings. +struct ShortcutDescription { + GameFunction action; + ShortcutGroup group; + std::string label; + bool fallback; +}; +inline ShortcutDescription DescribeShortcut(GameFunction action, std::string_view key, + std::span catalog = ShortcutCatalog) +{ + if (action < 0 || action >= GameFunction::Max) + throw std::out_of_range("shortcut action"); + for (const auto& info : catalog) + if (info.action == action) + return {action, info.group, std::string(info.label), false}; + return {action, ShortcutGroup::Uncategorized, HumanizeShortcutKey(key), true}; +} + +// Discover registered actions once at startup, including unbound (NONE) actions. +// An empty key excludes an unregistered or unavailable action. This never scans +// TOML or infers a setting type from its value. +inline std::vector DiscoverShortcuts(const auto& registeredKey, + std::span catalog = ShortcutCatalog) +{ + std::vector result; + for (int i = 0; i < GameFunction::Max; ++i) { + const auto action = static_cast(i); + const auto key = registeredKey(action); + if (!key.empty()) + result.push_back(DescribeShortcut(action, key, catalog)); + } + // ASCII folding preserves existing English label ordering (Away Teams, etc.). + std::stable_sort(result.begin(), result.end(), [](const auto& first, const auto& second) { + return std::lexicographical_compare( + first.label.begin(), first.label.end(), second.label.begin(), second.label.end(), [](char a, char b) { + const auto lower = [](char c) { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; }; + return lower(a) < lower(b); + }); + }); + return result; +} +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_draft.h b/mods/src/settings/shortcut_draft.h new file mode 100644 index 000000000..f419e7d8b --- /dev/null +++ b/mods/src/settings/shortcut_draft.h @@ -0,0 +1,88 @@ +#pragma once +#include "value_settings.h" +#include +#include + +namespace mod_settings +{ +using ShortcutList = std::vector; +enum class ShortcutStage { Staged, AlreadyBound, Invalid }; +// A draft edits one alternative, or restores the action's complete default list, +// against the snapshot the user saw. +// The existing ValueSetting owner provides reentry/stale-read protection. +class ShortcutDraft +{ +public: + using SameBinding = std::function; + explicit ShortcutDraft(ValueSetting& owner, SameBinding sameBinding = std::equal_to{}) + : owner_(owner) + , same_binding_(std::move(sameBinding)) + { + } + void Begin() + { + Cancel(); + observed_ = owner_.Observe(); + } + ShortcutStage Stage(std::size_t index, std::optional replacement) + { + desired_.reset(); + if (!observed_.state.known()) { + Cancel(); + return ShortcutStage::Invalid; + } + auto value = *observed_.state.value; + if (index > value.size() || (!replacement && index == value.size())) { + Cancel(); + return ShortcutStage::Invalid; + } + // Reject an existing alternative without changing the snapshot or publishing + // a draft. Re-recording the selected binding is also a no-op. + if (replacement && std::any_of(value.begin(), value.end(), [&](const auto& binding) { + return same_binding_(binding, *replacement); + })) + return ShortcutStage::AlreadyBound; + if (!replacement) + value.erase(value.begin() + index); + else if (index == value.size()) + value.push_back(*replacement); + else + value[index] = *replacement; + desired_ = std::move(value); + return ShortcutStage::Staged; + } + bool pending() const + { return desired_.has_value(); } + ShortcutStage Restore(ShortcutList defaults) + { + desired_.reset(); + if (!observed_.state.known()) + return ShortcutStage::Invalid; + if (defaults == *observed_.state.value) + return ShortcutStage::AlreadyBound; + desired_ = std::move(defaults); + return ShortcutStage::Staged; + } + const std::optional& desired() const + { return desired_; } + Outcome Apply() + { + if (!desired_) + return Outcome::Suppressed; + const auto result = owner_.SetFromUser(*desired_, observed_).outcome; + Cancel(); + return result; + } + void Cancel() + { + desired_.reset(); + observed_ = {}; + } + +private: + ValueSetting& owner_; + SameBinding same_binding_; + ValueSnapshot observed_; + std::optional desired_; +}; +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_popup.h b/mods/src/settings/shortcut_popup.h new file mode 100644 index 000000000..0d99c47d0 --- /dev/null +++ b/mods/src/settings/shortcut_popup.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include + +namespace mod_settings +{ +// The feature owns the draft; the native surface only renders and sends commands. +struct ShortcutPopupPresentation { + std::string title, current, proposed, status, confirmLabel; + bool canConfirm = false, canRecord = false; + bool operator==(const ShortcutPopupPresentation&) const = default; +}; +struct ShortcutPopupCommands { + std::function read; + std::function confirm, record, cancel; +}; +namespace native +{ +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + void InstallShortcutPopup(); + bool OpenShortcutPopup(ShortcutPopupCommands commands); + void CloseShortcutPopup(); +#else + inline void InstallShortcutPopup() {} + inline bool OpenShortcutPopup(ShortcutPopupCommands) + { return false; } + inline void CloseShortcutPopup() {} +#endif +} // namespace native +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_popup_keys.h b/mods/src/settings/shortcut_popup_keys.h new file mode 100644 index 000000000..b9c8b12e2 --- /dev/null +++ b/mods/src/settings/shortcut_popup_keys.h @@ -0,0 +1,33 @@ +#pragma once + +namespace mod_settings +{ +enum class ShortcutPopupKey { None, Confirm, Cancel }; +// Confirm needs a fresh press after the preview is ready. In particular, an +// Enter captured as the binding must be released before it can confirm itself. +class ShortcutPopupKeys +{ +public: + ShortcutPopupKey Tick(bool canConfirm, bool enterHeld, bool enterDown, bool escapeDown) + { + if (escapeDown) { + armed_ = false; + return ShortcutPopupKey::Cancel; + } + if (!canConfirm) { + armed_ = false; + return ShortcutPopupKey::None; + } + if (enterDown && armed_) { + armed_ = false; + return ShortcutPopupKey::Confirm; + } + if (!enterHeld && !enterDown) + armed_ = true; + return ShortcutPopupKey::None; + } + +private: + bool armed_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_settings.cc b/mods/src/settings/shortcut_settings.cc new file mode 100644 index 000000000..290e9ae90 --- /dev/null +++ b/mods/src/settings/shortcut_settings.cc @@ -0,0 +1,469 @@ +#include "shortcut_settings.h" +#include "config.h" +#include "page_catalog.h" +#include "patches/key.h" +#include "patches/mapkey.h" +#include "patches/runtime_config.h" +#include "patches/screen_update_hook.h" +#include "persisted_setting.h" +#include "shortcut_capture.h" +#include "shortcut_catalog.h" +#include "shortcut_draft.h" +#include "shortcut_popup.h" +#include "shortcut_undo.h" +#include "str_utils.h" +#include +#include +#include +#include + +namespace mod_settings +{ +namespace +{ + void (*changed)() = nullptr; + void Notify() + { + if (changed) + changed(); + } + std::string Join(const ShortcutList& list) + { + std::string result; + for (const auto& text : list) { + if (!result.empty()) + result += " | "; + result += text; + } + return result.empty() ? "NONE" : result; + } + ShortcutList DefaultBindings(GameFunction action) + { + ShortcutList values; + const auto& text = MapKey::Definition(action).defaultBinding; + if (AsciiStrToUpper(StripAsciiWhitespace(text)) != "NONE") { + for (const auto& token : StrSplit(text, '|')) { + const auto parsed = MapKey::Parse(StripAsciiWhitespace(token)); + if (parsed.Key == KeyCode::None) + throw std::invalid_argument("invalid default shortcut"); + values.push_back(parsed.GetParsedValues()); + } + } + return values; + } + bool captureReady = false; + std::string ShortcutUnavailableReason(GameFunction action) + { + if (!Config::Get().installHotkeyHooks) + return "Mod shortcut hooks are disabled in TOML."; + if (Config::Get().use_scopely_hotkeys) + return "Native shortcut mode is active. Disable it and restart to edit mod shortcuts."; + if (!captureReady) + return "Shortcut recording is unavailable for this client."; + if (action == GameFunction::ToggleShortcutHints && !ShortcutHintControlAvailable()) + return "Shortcut hint controls are unavailable for this client."; + return {}; + } + struct Editor { + GameFunction function; + ValueSetting state; + ShortcutDraft draft; + ShortcutUndo undo; + std::string status, replacing; + std::vector overlaps; + std::vector> rows; + Editor(GameFunction action, const std::string& label) + : function(action) + , state(PersistedDefinition( + {"community_mod.shortcuts." + MapKey::Definition(action).key, label, + [action] { + ShortcutList list; + for (const auto& binding : MapKey::Bindings(action)) + list.push_back(binding.GetParsedValues()); + return ValueReadResult::Known(std::move(list), 1); + }, + [this, action](ShortcutList list, std::uint64_t generation) { + if (generation != 1 || !ShortcutUnavailableReason(action).empty()) + return ApplyResult::Rejected; + // Existing oversized lists may be reduced/rebound, but a UI + // addition must fit before either publication or persistence. + if (!ShortcutCountFitsEdit(MapKey::Bindings(action).size(), list.size()) && !undo.Restoring(list)) + return ApplyResult::Rejected; + std::vector bindings; + for (const auto& text : list) { + auto parsed = MapKey::Parse(text); + if (parsed.Key == KeyCode::None) + return ApplyResult::Rejected; + bindings.push_back(std::move(parsed)); + } + if (!MapKey::ReplaceBindings(action, std::move(bindings))) + return ApplyResult::Rejected; + return ApplyResult::Applied; + }}, + {"shortcuts", MapKey::Definition(action).key, std::string(ShortcutExplanation(action)), "", + ApplyTiming::Live}, + DefaultBindings(action), [](const ShortcutList& list) -> config_edit::Value { return Join(list); })) + , draft(state, + [](const auto& first, const auto& second) { + return MapKey::SameBinding(MapKey::Parse(first), MapKey::Parse(second)); + }) + , undo(state) + { + } + bool Available() const + { return ShortcutUnavailableReason(function).empty(); } + ShortcutList Current() + { + auto snapshot = state.Observe(); + return snapshot.state.value.value_or(ShortcutList{}); + } + }; + std::vector> editors; + Editor* recording = nullptr; + std::size_t recordingIndex = 0; + ShortcutCapture capture; + std::vector sampledKeys; + bool (*isFocused)() = nullptr; + Editor* popupEditor = nullptr; + + // Compare the dispatcher's modifier rules as well as the physical key. + // Contexts may still make an overlap intentional; warn without removing either. + std::vector Overlaps(Editor& editor, const std::string& token) + { + const auto candidate = MapKey::Parse(token); + std::vector result; + // These direct Enter handlers are outside the configurable MapKey bindings. + if (candidate.Key == KeyCode::Return || candidate.Key == KeyCode::KeypadEnter) { + result.push_back(token + " may also confirm game dialogs."); + if (Config::Get().double_click_to_assign_ship) + result.push_back(token + " also assigns the selected ship while the ship selection screen is open."); + } + for (int i = 0; i < GameFunction::Max; ++i) { + const auto action = static_cast(i); + if (action == editor.function) + continue; + bool overlap = false; + for (const auto& binding : MapKey::Bindings(action)) + overlap |= MapKey::MayOverlap(candidate, binding); + if (!overlap) + continue; + result.push_back(token + " may overlap: " + DescribeShortcut(action, MapKey::Definition(action).key).label); + } + return result; + } + + void Cancel(Editor& editor, bool closePopup = true) + { + if (closePopup && popupEditor == &editor) { + popupEditor = nullptr; + native::CloseShortcutPopup(); + // Closing from preview still owns Escape/Enter/the mouse through release. + capture.Begin(); + capture.Cancel(); + Key::shortcutCaptureActive = true; + } + if (recording == &editor) { + recording = nullptr; + capture.Cancel(); + } + editor.draft.Cancel(); + editor.undo.Clear(); + editor.overlaps.clear(); + editor.status.clear(); + } + void Begin(Editor& editor, std::size_t index, bool keepPopup = false) + { + if (!editor.Available() || capture.active()) + return; + Cancel(editor, !keepPopup); + editor.draft.Begin(); + const auto list = editor.Current(); + if (index > list.size() || (index == list.size() && list.size() >= ShortcutBindingDisplayLimit)) + return; + recordingIndex = index; + editor.replacing = index < list.size() ? list[index] : ""; + recording = &editor; + capture.Begin(); + Key::shortcutCaptureActive = true; + editor.status = "Release keys, then press a shortcut. Esc cancels."; + } + void OpenEditor(Editor& editor, std::size_t index) + { + Begin(editor, index); + if (recording != &editor) + return; + popupEditor = &editor; + const bool opened = + native::OpenShortcutPopup({[&editor, index] { + ShortcutPopupPresentation view; + view.title = editor.state.label(); + view.current = editor.replacing.empty() ? "Unbound" : editor.replacing; + if (editor.draft.desired() && index < editor.draft.desired()->size()) + view.proposed = editor.draft.desired()->at(index); + view.status = editor.status; + for (const auto& warning : editor.overlaps) + view.status += "\n" + warning + ""; + view.confirmLabel = editor.overlaps.empty() ? "Confirm" : "Use anyway"; + view.canConfirm = editor.draft.pending() && !capture.active(); + view.canRecord = !capture.active(); + return view; + }, + [&editor, index] { + if (!editor.draft.pending() || capture.active()) + return; + auto overlaps = Overlaps(editor, editor.draft.desired()->at(index)); + if (overlaps != editor.overlaps) { + editor.overlaps = std::move(overlaps); + editor.status = "Shortcut usage changed. Check the warnings and confirm again."; + return; + } + const auto outcome = editor.draft.Apply(); + if (outcome == Outcome::AppliedVerified || outcome == Outcome::Unchanged) { + Cancel(editor); + spdlog::info("[Shortcuts] Popup binding applied: {}", editor.state.id()); + } else { + editor.status = "Binding changed; cancel and reopen to try again."; + } + Notify(); + }, + [&editor, index] { Begin(editor, index, true); }, + [&editor] { + Cancel(editor); + Notify(); + }}); + if (!opened) { + popupEditor = nullptr; + Cancel(editor); + editor.status = "Popup unavailable; no binding changed."; + } + } + void UpdateCapture() + { + if (!capture.active()) + return; // No input scan or logging while idle. + try { + ShortcutCapture::Keys held{}, down{}; + for (auto key : sampledKeys) { + held[static_cast(key)] = Key::RawPressed(key); + down[static_cast(key)] = Key::RawDown(key); + } + const bool hasFocus = isFocused(); + const bool cancel = !hasFocus || down[static_cast(KeyCode::Escape)]; + auto* editor = recording; + const auto primary = capture.Tick(held, down, hasFocus, Key::IsModifier); + if (editor && cancel) { + Cancel(*editor); + Notify(); + } else if (editor && primary) { + const auto isHeld = [&](KeyCode key) { return held[static_cast(key)]; }; + const auto key = *primary; + // Generic modifiers for new bindings, matching ordinary TOML bindings. + // Existing sided modifiers remain untouched unless that binding is replaced. + std::string token = CaptureModifierPrefix(isHeld); + if (key == KeyCode::None || isHeld(KeyCode::AltGr)) { + editor->status = "This key is unavailable; record another"; + } else { + token += Key::Token(key); + const auto result = editor->draft.Stage(recordingIndex, token); + editor->overlaps.clear(); + if (result == ShortcutStage::Staged) { + editor->overlaps = Overlaps(*editor, token); + editor->status = editor->replacing.empty() ? "Add " + token : editor->replacing + " -> " + token; + } else { + editor->status = result == ShortcutStage::AlreadyBound ? "Already bound: " + token + : "Binding unavailable; reopen and try again"; + } + } + recording = nullptr; + Notify(); + } else if (editor && !capture.active()) { + recording = nullptr; + editor->status = "Recording cancelled; press one primary key"; + Notify(); + } + const bool wasBlocked = Key::shortcutCaptureActive; + Key::shortcutCaptureActive = capture.active(); + if (wasBlocked != Key::shortcutCaptureActive) + Notify(); + } catch (...) { + capture.Cancel(); + if (recording) { + recording->draft.Cancel(); + recording = nullptr; + } + // Continue draining via raw input on subsequent frames; never leave a draft + // from a failed capture eligible for application. + static bool warned = false; + if (!warned) { + warned = true; + spdlog::warn("[Shortcuts] Key recording unavailable"); + } + } + } + void AddRows(PageCatalog& catalog, Editor& editor) + { + catalog.OnLeave(editor.state.id(), [&editor] { Cancel(editor); }); + catalog.SetSummary(editor.state.id(), [&editor] { + if (!editor.Available()) + return std::string{"Unavailable this session"}; + const auto& bindings = MapKey::Bindings(editor.function); + if (bindings.empty()) + return std::string{"Unbound"}; + return bindings.front().GetParsedValues() + + (bindings.size() > 1 ? " +" + std::to_string(bindings.size() - 1) : ""); + }); + auto add = [&](const char* id, const char* label, auto read, auto invoke, std::function count = {}) { + auto row = std::make_unique(); + row->identity = editor.state.id() + "." + id; + row->label = label; + row->read = [&editor, read](std::size_t index) { + auto presentation = read(index); + if (!editor.Available()) + presentation.enabled = false; + return presentation; + }; + row->invoke = [&editor, invoke](std::size_t index) { + if (!editor.Available()) + return; + invoke(index); + Notify(); + }; + row->count = std::move(count); + catalog.AddAction(editor.state.id(), *row); + editor.rows.push_back(std::move(row)); + }; + using P = ActionSetting::Presentation; + add( + "status", "Shortcut status", + [&editor](std::size_t) { + if (popupEditor == &editor) + return P{"", "", "", false, false}; + std::string label = editor.undo.available() ? editor.undo.message() : editor.status; + const auto unavailable = ShortcutUnavailableReason(editor.function); + if (!unavailable.empty()) + label = unavailable; + const auto saved = SaveStatusText(editor.state.save_status()); + if (*saved) { + if (!label.empty()) + label += "\n"; + label += saved; + } + return P{label, editor.undo.available() ? "Undo" : "", "", editor.undo.available() && !capture.active(), + !label.empty()}; + }, + [&editor](std::size_t) { + if (editor.undo.available()) { + const auto result = editor.undo.Undo(); + editor.status = result == Outcome::AppliedVerified ? "" + : result == Outcome::Conflict ? "Bindings changed; undo cancelled." + : "Could not verify undo; check current bindings."; + } + }); + const auto help = ShortcutExplanation(editor.function); + if (!help.empty()) + add( + "help", "About this shortcut", [help](std::size_t) { return P{std::string(help), "", "", false}; }, + [](std::size_t) {}); + auto count = [&editor] { return VisibleShortcutBindingCount(editor.Current().size()); }; + add( + "binding", "Shortcut", + [&editor](std::size_t index) { + const auto list = editor.Current(); + return P{list.at(index), "Change", "", + popupEditor != &editor && !capture.active() && !editor.draft.pending()}; + }, + [&editor](std::size_t index) { OpenEditor(editor, index); }, count); + add( + "add", "Add shortcut", + [&editor](std::size_t) { + const auto size = editor.Current().size(); + if (size > ShortcutBindingDisplayLimit) + return P{"Showing first " + std::to_string(ShortcutBindingDisplayLimit) + "; remove shortcuts or edit TOML", + "", "", false}; + if (size == ShortcutBindingDisplayLimit) + return P{"Remove a shortcut before adding another", "", "", false}; + return P{size == 0 ? "No shortcut assigned" : "Add shortcut", "Record", "", + popupEditor != &editor && !capture.active() && !editor.draft.pending()}; + }, + [&editor](std::size_t) { OpenEditor(editor, editor.Current().size()); }); + add( + "remove", "Remove shortcut", + [&editor](std::size_t index) { + return P{editor.Current().at(index), "Remove", "", !capture.active() && !editor.draft.pending(), + popupEditor != &editor}; + }, + [&editor](std::size_t index) { + const auto list = editor.Current(); + if (index >= list.size()) + return; + Cancel(editor); + const auto result = editor.undo.Remove(index); + editor.status = result == Outcome::AppliedVerified ? "Removed " + list[index] + : "Could not verify removal; check current bindings."; + }, + count); + // Rendering and restore consume the same defaults declared on the model. + const auto defaults = editor.state.default_value(); + add( + "default", "Restore default", + [&editor, defaults](std::size_t) { + return P{"Default: " + MapKey::Definition(editor.function).defaultBinding, "Restore", "", + defaults.has_value() && !capture.active() && !editor.draft.pending(), + popupEditor != &editor && (!defaults || editor.Current() != *defaults)}; + }, + [&editor, defaults](std::size_t) { + if (!defaults) + return; + Cancel(editor); + const auto result = editor.undo.Restore(*defaults); + editor.status = result == Outcome::AppliedVerified ? "Restored defaults" + : result == Outcome::Unchanged ? "Already using defaults" + : "Could not verify restore; check current bindings."; + }); + } +} // namespace + +void SetShortcutPresentationObserver(void (*observer)()) +{ changed = observer; } +void RegisterShortcutPages(PageCatalog& catalog) +{ + if (!editors.empty()) + return; +// Capture preserves the same physical KeyCode tokens used by the dispatcher. +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + // Capture must observe a focused release before returning keys to gameplay. + // A missing query is not ordinary focus loss: never offer capture without it. + isFocused = il2cpp_resolve_icall_typed("UnityEngine.Application::get_isFocused()"); + captureReady = + isFocused && install_screen_manager_update_hook() && register_screen_manager_update_callback(UpdateCapture); + if (!captureReady) + spdlog::warn("[Shortcuts] Recording unavailable; registered actions remain visible"); + for (int i = 1; i < static_cast(KeyCode::Max); ++i) { + const auto key = static_cast(i); + if (!Key::Token(key).empty()) + sampledKeys.push_back(key); + } + catalog.AddPage("community_mod.shortcuts", "Shortcuts", "community_mod.settings"); + for (const auto& group : ShortcutGroups) + catalog.AddPage(std::string("community_mod.shortcuts.") + std::string(group.id), std::string(group.label), + "community_mod.shortcuts"); + const auto ordered = + DiscoverShortcuts([](GameFunction action) -> std::string_view { return MapKey::Definition(action).key; }); + for (const auto& info : ordered) { +#ifdef _MODDBG + if (info.fallback) + spdlog::warn("[Shortcuts] {} has no presentation override; listed in Uncategorized", + MapKey::Definition(info.action).key); +#endif + auto editor = std::make_unique(info.action, info.label); + const auto group = std::find_if(ShortcutGroups.begin(), ShortcutGroups.end(), + [&](const auto& group) { return group.group == info.group; }); + catalog.AddPage(editor->state.id(), editor->state.label(), + std::string("community_mod.shortcuts.") + std::string(group->id)); + catalog.SetMetadata(editor->state.id(), editor->state.metadata()); + AddRows(catalog, *editor); + editors.push_back(std::move(editor)); + } +#endif +} +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_settings.h b/mods/src/settings/shortcut_settings.h new file mode 100644 index 000000000..127084985 --- /dev/null +++ b/mods/src/settings/shortcut_settings.h @@ -0,0 +1,8 @@ +#pragma once +namespace mod_settings +{ +class PageCatalog; +void RegisterShortcutPages(PageCatalog& catalog); +void SetShortcutPresentationObserver(void (*observer)()); +bool ShortcutHintControlAvailable(); +} // namespace mod_settings diff --git a/mods/src/settings/shortcut_undo.h b/mods/src/settings/shortcut_undo.h new file mode 100644 index 000000000..fc0b4991e --- /dev/null +++ b/mods/src/settings/shortcut_undo.h @@ -0,0 +1,82 @@ +#pragma once +#include "shortcut_draft.h" + +namespace mod_settings +{ +// One immediate change per editor. Undo restores the exact list (including order and +// existing duplicates), and cannot overwrite a subsequent binding change. +class ShortcutUndo +{ +public: + explicit ShortcutUndo(ValueSetting& owner) + : owner_(owner) + { + } + Outcome Remove(std::size_t index) + { + Clear(); + const auto before = owner_.Observe(); + if (!before.state.known() || index >= before.state.value->size()) + return Outcome::Rejected; + auto desired = *before.state.value; + const auto removed = desired[index]; + desired.erase(desired.begin() + index); + return Commit(std::move(desired), before, "Removed " + removed); + } + Outcome Restore(ShortcutList defaults) + { + Clear(); + const auto before = owner_.Observe(); + if (!before.state.known()) + return Outcome::Rejected; + return Commit(std::move(defaults), before, "Restored defaults"); + } + Outcome Undo() + { + if (!original_) + return Outcome::Suppressed; + struct Scope { + bool& active; + explicit Scope(bool& value) + : active(value) + { active = true; } + ~Scope() + { active = false; } + } scope(restoring_); + const auto result = owner_.SetFromUser(*original_, after_).outcome; + Clear(); + return result; + } + bool available() const + { return original_.has_value(); } + const std::string& message() const + { return message_; } + // An oversized player-authored list may be restored only by this exact undo, + // not used as permission for arbitrary additions beyond the UI's row budget. + bool Restoring(const ShortcutList& value) const + { return restoring_ && original_ && value == *original_; } + void Clear() + { + original_.reset(); + after_ = {}; + message_.clear(); + } + +private: + Outcome Commit(ShortcutList desired, const ValueSnapshot& before, std::string message) + { + const auto result = owner_.SetFromUser(std::move(desired), before); + if (result.outcome == Outcome::AppliedVerified) { + original_ = *before.state.value; + after_ = result.snapshot; + message_ = std::move(message); + } + return result.outcome; + } + ValueSetting& owner_; + std::optional original_; + ValueSnapshot after_; + std::string message_; + bool restoring_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/slider_setting.h b/mods/src/settings/slider_setting.h new file mode 100644 index 000000000..0793afe5d --- /dev/null +++ b/mods/src/settings/slider_setting.h @@ -0,0 +1,87 @@ +#pragma once +#include "value_view.h" +#include +#include +#include +namespace mod_settings +{ +enum class SliderLabel { Value, Percentage }; + +class SliderSetting +{ +public: + SliderSetting(ValueDefinition definition, float minimum, float maximum, float step, + std::function enabled, SliderLabel label = SliderLabel::Percentage, + std::uint8_t displayDecimals = 2, std::string disabledReason = {}) + : state_(Checked(std::move(definition), minimum, maximum, enabled)) + , minimum_(minimum) + , maximum_(maximum) + , step_(step) + , enabled_(std::move(enabled)) + , label_(label) + , displayDecimals_(displayDecimals) + , disabledReason_(std::move(disabledReason)) + { + if (!std::isfinite(step) || step <= 0) + throw std::invalid_argument("slider step"); + } + ValueSetting& state() + { return state_; } + float minimum() const + { return minimum_; } + float maximum() const + { return maximum_; } + SliderLabel label() const + { return label_; } + float DisplayValue(float value) const + { + // Presentation only: never quantize a loaded preference or change its step. + const auto scale = std::pow(10.0, displayDecimals_); + return static_cast(std::round(static_cast(value) * scale) / scale); + } + bool enabled() const + { return enabled_(); } + const std::string& disabledReason() const + { return disabledReason_; } + float Snap(float value) const + { + return std::clamp( + static_cast(minimum_ + std::round((static_cast(value) - minimum_) / step_) * step_), minimum_, + maximum_); + } + +private: + static ValueDefinition Checked(ValueDefinition definition, float minimum, float maximum, + const std::function& enabled) + { + if (!std::isfinite(minimum) || !std::isfinite(maximum) || minimum >= maximum || !enabled || !definition.read + || !definition.write) + throw std::invalid_argument("slider definition"); + auto read = std::move(definition.read); + auto write = std::move(definition.write); + definition.read = [read = std::move(read), minimum, maximum] { + auto value = read(); + if (value.known()) { + if (!std::isfinite(*value.value)) + return ValueReadResult{Availability::Unavailable, {}, 0, UnavailableReason::InvalidValue}; + if (*value.value < minimum || *value.value > maximum) + return ValueReadResult{Availability::Unavailable, {}, 0, UnavailableReason::OutsideRange}; + } + return value; + }; + definition.write = [write = std::move(write), minimum, maximum, enabled](float value, std::uint64_t generation) { + if (!enabled() || !std::isfinite(value) || value < minimum || value > maximum) + return ApplyResult::Rejected; + return write(value, generation); + }; + return definition; + } + ValueSetting state_; + float minimum_, maximum_, step_; + std::function enabled_; + SliderLabel label_; + std::uint8_t displayDecimals_; + // Feature-owned wording; the shared native widget does not know the dependency. + std::string disabledReason_; +}; +} // namespace mod_settings diff --git a/mods/src/settings/text_entry.h b/mods/src/settings/text_entry.h new file mode 100644 index 000000000..b39a2b523 --- /dev/null +++ b/mods/src/settings/text_entry.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +namespace mod_settings +{ +// The owning setting captures its observed revision in commit. Validation has +// no side effects; a rejected/stale commit keeps the editor open for correction. +struct TextEntryRequest { + std::string title, value, help; + std::function validate; + std::function commit; + std::function cancel; + bool read_only = false; + std::string apply_label = "Apply"; +}; +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +bool OpenTextEntry(TextEntryRequest request); +void CloseTextEntry(); +bool TextEntryActive(); +#else +inline bool OpenTextEntry(TextEntryRequest) +{ return false; } +inline void CloseTextEntry() {} +inline bool TextEntryActive() +{ return false; } +#endif +} // namespace mod_settings diff --git a/mods/src/settings/upstream_settings.cc b/mods/src/settings/upstream_settings.cc new file mode 100644 index 000000000..8a6cf1d62 --- /dev/null +++ b/mods/src/settings/upstream_settings.cc @@ -0,0 +1,800 @@ +#include "upstream_settings.h" + +#include "config.h" +#include "defaultconfig.h" +#include "file.h" +#include "forbidden_tech.h" +#include "mod_pages.h" +#include "patches/mission_hud.h" +#include "patches/notification_audio.h" +#include "patches/parts/action_queue.h" +#include "persisted_setting.h" +#include "text_entry.h" +#include "warp_mode.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +void RefreshFleetLabelControls(); +bool FleetLabelControlsAvailable(); + +namespace mod_settings +{ +namespace +{ + constexpr auto root = "community_mod.settings"; + using Timing = ApplyTiming; + using Available = std::function; + std::vector> adapters; + std::map, std::less<>> currentSavers; + + template std::shared_ptr Keep(Args&&... args) + { + auto value = std::make_shared(std::forward(args)...); + adapters.push_back(value); + return value; + } + + Available Hook(bool Config::* member) + { + return [member] { return Config::Get().*member; }; + } + + std::string Id(const char* section, const char* key) + { return std::string("community_mod.") + section + "." + key; } + + template std::string Format(const T& value) + { + if constexpr (std::is_same_v) + return value; + else { + char text[128]; + const auto result = std::to_chars(text, text + sizeof(text), value); + return result.ec == std::errc{} ? std::string(text, result.ptr) : std::string{}; + } + } + + std::string_view Trim(std::string_view text) + { + constexpr auto whitespace = " \t\r\n"; + const auto first = text.find_first_not_of(whitespace); + return first == text.npos ? std::string_view{} : text.substr(first, text.find_last_not_of(whitespace) - first + 1); + } + + template std::optional ParseNumber(std::string_view text, T minimum, T maximum) + { + text = Trim(text); + if (text.empty()) + return {}; + T value{}; + const auto result = std::from_chars(text.data(), text.data() + text.size(), value); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size() || value < minimum || value > maximum) + return {}; + if constexpr (std::is_floating_point_v) + if (!std::isfinite(value)) + return {}; + return value; + } + + template + ValueDefinition MakeDefinition(const char* section, const char* key, const char* label, T defaultValue, Read read, + Write write, Available available, Timing timing, const char* help, const char* unit, + Serializer serializer) + { + auto requested = std::make_shared(read()); + ValueDefinition definition{Id(section, key), label, + [read, requested, available, timing] { + return available() ? ValueReadResult::Known( + timing == Timing::Restart ? *requested : read(), 1) + : ValueReadResult{}; + }, + [write, requested, available, timing](T value, std::uint64_t generation) { + if (generation != 1 || !available()) + return ApplyResult::Rejected; + if (timing == Timing::Restart) + *requested = std::move(value); + else if (!write(value)) + return ApplyResult::Rejected; + return ApplyResult::Applied; + }}; + if (timing == Timing::Restart) + definition.active_value = [read]() -> std::optional { return read(); }; + auto result = PersistedDefinition(std::move(definition), {section, key, help, unit, timing}, + std::move(defaultValue), std::move(serializer)); + currentSavers.insert_or_assign(result.metadata.TomlKey(), [read, available, persist = result.persist] { + if (!available() || !persist) + return false; + persist(read()); + return true; + }); + return result; + } + + template + ValueDefinition MakeDefinition(const char* section, const char* key, const char* label, T defaultValue, Read read, + Write write, Available available, Timing timing, const char* help, + const char* unit = "") + { + return MakeDefinition(section, key, label, defaultValue, read, write, std::move(available), timing, help, unit, + [](const T& value) -> config_edit::Value { + if constexpr (std::is_integral_v && !std::is_same_v) + return static_cast(value); + else if constexpr (std::is_floating_point_v) + return static_cast(value); + else + return value; + }); + } + + void Boolean(PageCatalog& catalog, const char* page, const char* section, const char* key, const char* label, + bool Config::* member, bool defaultValue, Available available, Timing timing, const char* help, + std::function onChanged = {}) + { + auto setting = Keep(MakeDefinition( + section, key, label, defaultValue, [member] { return Config::Get().*member; }, + [member, onChanged = std::move(onChanged)](bool value) { + const bool changed = Config::Get().*member != value; + Config::Get().*member = value; + if (changed && onChanged) + onChanged(); + return true; + }, + std::move(available), timing, help)); + catalog.AddBoolean(page, *setting); + } + + // An exact editor uses its own canonical snapshot rather than the convenience + // slider's range-limited reader. Existing custom values remain readable and can + // be edited without first moving a slider or normalizing the TOML file. + template + void ExactEntry(PageCatalog& catalog, const char* page, const std::shared_ptr>& setting, Parse parse) + { + auto action = Keep(); + action->identity = setting->id() + ".exact"; + action->label = setting->label(); + action->metadata = &setting->metadata(); + action->reset = [setting] { setting->Reset(setting->Observe()); }; + action->retry = [setting] { setting->RetrySave(setting->Observe()); }; + action->prepare_reset = [setting] { return PrepareReset(*setting); }; + action->status = [setting] { return setting->status_text(); }; + action->save_status = [setting] { return setting->save_status(); }; + action->default_label = [setting] { + const auto value = setting->default_value(); + return value ? Format(*value) : std::string{}; + }; + action->read = [setting](std::size_t) { + const auto state = setting->Observe().state; + auto value = state.known() ? Format(*state.value) : std::string("Unavailable"); + if (value.empty()) + value = "(empty)"; + const auto status = setting->status_text(); + if (!status.empty()) + value += "\n" + status; + return ActionSetting::Presentation{setting->label(), "Edit", std::move(value), state.known()}; + }; + action->invoke = [setting, parse](std::size_t) { + const auto observed = setting->Observe(); + if (!observed.state.known()) + return; + auto help = setting->metadata().description; + if (!setting->metadata().unit.empty()) + help += "\nUnits: " + setting->metadata().unit; + if (const auto value = setting->default_value()) { + const auto text = Format(*value); + help += "\nDefault: " + (text.empty() ? std::string("(empty)") : text); + } + OpenTextEntry({.title = setting->label(), + .value = Format(*observed.state.value), + .help = std::move(help), + .validate = + [parse](std::string_view text) { + return parse(text) ? std::string{} : std::string("Enter a valid value."); + }, + .commit = + [setting, observed, parse](std::string_view text) { + const auto value = parse(text); + if (!value) + return false; + const auto result = setting->SetFromUser(*value, observed).outcome; + return result == Outcome::AppliedVerified || result == Outcome::Unchanged; + }}); + }; + catalog.AddAction(page, *action); + } + + template + void Number(PageCatalog& catalog, const char* page, const char* section, const char* key, const char* label, + T Config::* member, T defaultValue, Available available, Timing timing, const char* help, + const char* unit, T minimum, T maximum, float sliderMaximum = 0, float step = 1) + { + auto definition = MakeDefinition( + section, key, label, defaultValue, [member] { return Config::Get().*member; }, + [member, minimum, maximum](T value) { + if (value < minimum || value > maximum) + return false; + if constexpr (std::is_floating_point_v) + if (!std::isfinite(value)) + return false; + const auto before = Config::Get().*member; + Config::Get().*member = value; + if constexpr (std::is_same_v) + if (member == &Config::ui_scale_ship) + ApplyUiShipScaleToLoadedShips(before, value); + return true; + }, + available, timing, help, unit); + if constexpr (std::is_floating_point_v) { + if (minimum == std::numeric_limits::min()) + definition.metadata.description += "\nMust be greater than zero."; + else + definition.metadata.description += "\nMinimum: " + Format(minimum) + "."; + } else { + definition.metadata.description += "\nWhole numbers only. Minimum: " + Format(minimum) + "."; + } + if (maximum != std::numeric_limits::max()) + definition.metadata.description += " Maximum: " + Format(maximum) + "."; + if (sliderMaximum > static_cast(minimum)) + definition.metadata.description += "\nThe slider is a convenience range; Edit also accepts valid custom values."; + auto exact = Keep>(definition); + ExactEntry(catalog, page, exact, + [minimum, maximum](std::string_view text) { return ParseNumber(text, minimum, maximum); }); + if constexpr (std::is_same_v) { + if (sliderMaximum > static_cast(minimum)) { + auto slider = Keep(std::move(definition), static_cast(minimum), sliderMaximum, step, + std::move(available), SliderLabel::Value, 2); + catalog.AddSlider(page, *slider); + } + } + } + + template + void Choice(PageCatalog& catalog, const char* page, const char* section, const char* key, const char* label, + std::vector labels, std::vector values, int defaultValue, Read read, + Write write, Available available, Timing timing, const char* help) + { + auto setting = + Keep(MakeDefinition( + section, key, label, defaultValue, read, + [write, count = values.size()](int value) { + return value >= 0 && static_cast(value) < count && write(value); + }, + std::move(available), timing, help, "", + [values](const int& value) -> config_edit::Value { return values.at(value); }), + std::move(labels)); + catalog.AddChoice(page, *setting); + } + + void Information(PageCatalog& catalog, const char* page, const char* id, const char* label, + std::function text) + { + auto action = Keep(); + action->identity = id; + action->label = label; + action->read = [label, text = std::move(text)](std::size_t) { + return ActionSetting::Presentation{label, "", text(), false}; + }; + action->invoke = [](std::size_t) {}; + catalog.AddAction(page, *action); + } + + std::string Join(const std::vector& names) + { + std::string text; + for (const auto& name : names) { + if (!text.empty()) + text += ", "; + text += name; + } + return text; + } + + int ChoiceDefault(std::string_view name, std::initializer_list values) + { + int index = 0; + for (const auto value : values) { + if (value == name) + return index; + ++index; + } + throw std::invalid_argument("unknown default choice"); + } + + void Lists(PageCatalog& catalog) + { + constexpr auto page = "community_mod.travel.filters"; + const auto available = Hook(&Config::installInstantWarpConfirmationHooks); + struct Filter { + const char* key; + const char* label; + std::vector Config::* names; + bool Config::* all; + const char* defaultValue; + }; + constexpr Filter filters[]{ + {"instant_warp_auto_jump", "Always jump with these ships", &Config::instant_warp_auto_jump, + &Config::instant_warp_auto_jump_all, DefaultConfig::UI::instant_warp_auto_jump}, + {"instant_warp_auto_warp", "Always warp with these ships", &Config::instant_warp_auto_warp, + &Config::instant_warp_auto_warp_all, DefaultConfig::UI::instant_warp_auto_warp}, + {"instant_warp_always_ask", "Always ask for these ships", &Config::instant_warp_always_ask, + &Config::instant_warp_always_ask_all, DefaultConfig::UI::instant_warp_always_ask}, + {"pinned_ships", "Pinned fleet dock ships", &Config::pinned_ships, nullptr, DefaultConfig::UI::pinned_ships}}; + for (const auto& filter : filters) { + auto read = [filter] { + return filter.all && Config::Get().*filter.all ? std::string("*") : Join(Config::Get().*filter.names); + }; + auto parse = [filter](std::string_view text) -> std::optional { + std::vector names; + bool all = false; + if (!ParseShipFilter(text, names, all) || (!filter.all && all)) + return {}; + return all ? std::string("*") : Join(names); + }; + auto setting = Keep>(MakeDefinition( + "ui", filter.key, filter.label, filter.defaultValue, read, + [filter](const std::string& text) { + std::vector names; + bool all = false; + if (!ParseShipFilter(text, names, all) || (!filter.all && all)) + return false; + Config::Get().*filter.names = std::move(names); + if (filter.all) + Config::Get().*filter.all = all; + return true; + }, + filter.all ? available : Hook(&Config::installPinnedShipSortHooks), Timing::ReopenView, + "Comma-separated ship names. Warp filters accept * for every ship. Pins match idle ships by name; reopen " + "fleet management after changing them.")); + ExactEntry(catalog, page, setting, parse); + } + auto factions = Keep>(MakeDefinition( + "ui", "daily_bulk_claim_factions", "Daily bulk-claim factions", DefaultConfig::UI::daily_bulk_claim_factions, + [] { return Join(Config::Get().daily_bulk_claim_factions); }, + [](const std::string& text) { + std::vector values; + if (!ParseFactionFilter(text, values)) + return false; + Config::Get().daily_bulk_claim_factions = std::move(values); + return true; + }, + Hook(&Config::installDailyFactionBulkClaimHooks), Timing::ReopenView, + "Comma-separated faction names; reopen the daily faction claim screen after changing the list.")); + ExactEntry(catalog, "community_mod.claims", factions, [](std::string_view text) -> std::optional { + std::vector values; + return ParseFactionFilter(text, values) ? std::optional{Join(values)} : std::nullopt; + }); + for (const bool notify : {false, true}) { + auto setting = Keep>(MakeDefinition( + "ui", notify ? "notify_banner_types" : "disabled_banner_types", + notify ? "Banners allowed to notify" : "Hidden banner types", + notify ? DefaultConfig::UI::notify_banner_types : DefaultConfig::UI::disabled_banner_types, + [notify] { + return FormatBannerFilter(notify ? Config::Get().notify_banner_types : Config::Get().disabled_banner_types); + }, + [notify](const std::string& text) { + std::vector values; + if (!ParseBannerFilter(text, values)) + return false; + (notify ? Config::Get().notify_banner_types : Config::Get().disabled_banner_types) = std::move(values); + return true; + }, + Hook(&Config::installToastBannerHooks), Timing::Live, + "Comma-separated banner names from the English example configuration. Unknown names are rejected.")); + ExactEntry(catalog, "community_mod.notifications", setting, + [](std::string_view text) -> std::optional { + std::vector values; + return ParseBannerFilter(text, values) ? std::optional{FormatBannerFilter(values)} : std::nullopt; + }); + } + } +} // namespace + +bool PersistCurrentSetting(std::string_view section, std::string_view key) +{ + // Existing native pin/shortcut actions still persist when the settings UI's + // emergency hook switch is disabled. Call only from the game UI thread. + try { + if (currentSavers.empty()) + RegisterUpstreamSettings(ModPages()); + const auto found = currentSavers.find(std::string(section) + "." + std::string(key)); + return found != currentSavers.end() && found->second(); + } catch (...) { + return false; + } +} + +void RegisterUpstreamSettings(PageCatalog& catalog) +{ + if (!adapters.empty()) + return; + constexpr float positive = std::numeric_limits::min(); + constexpr float largest = std::numeric_limits::max(); + constexpr int integer = std::numeric_limits::max(); + const Available always = [] { return true; }; + namespace C = DefaultConfig::Control; + namespace G = DefaultConfig::Graphics; + namespace U = DefaultConfig::UI; + namespace B = DefaultConfig::Buffs; + const auto hotkeys = Hook(&Config::installHotkeyHooks); + const auto zoom = Hook(&Config::installZoomHooks); + const Available fleetLabels = [zoom] { return zoom() && FleetLabelControlsAvailable(); }; + const auto scale = Hook(&Config::installUiScaleHooks); + const auto misc = Hook(&Config::installMiscPatches); + const auto toast = Hook(&Config::installToastBannerHooks); + for (const auto& page : + std::initializer_list>{{"controls", "Controls"}, + {"camera", "Camera"}, + {"interface", "Interface & Scale"}, + {"travel", "Map & Travel"}, + {"previews", "Previews & Cargo"}, + {"claims", "Claims & Confirmations"}, + {"officers", "Officers & Ships"}, + {"chat", "Chat"}, + {"notifications", "Notifications"}, + {"hud", "HUD Buttons"}, + {"labels", "Fleet Labels"}, + {"loading", "Loading Screens"}, + {"advanced", "Advanced & Configuration"}}) + catalog.AddPage(std::string("community_mod.") + page.first, page.second, root); + catalog.AddPage("community_mod.travel.filters", "Ship Filters & Pins", "community_mod.travel"); + +#define BOOL(page, section, key, label, member, def, hook, timing, help) \ + Boolean(catalog, "community_mod." page, section, key, label, &Config::member, def, hook, Timing::timing, help) +#define NUM(page, section, key, label, member, def, hook, timing, help, unit, low, high, slider, step) \ + Number(catalog, "community_mod." page, section, key, label, &Config::member, decltype(Config::member)(def), hook, \ + Timing::timing, help, unit, decltype(Config::member)(low), decltype(Config::member)(high), slider, step) + + BOOL("controls", "control", "hotkeys_enabled", "Enable mod shortcuts", hotkeys_enabled, C::hotkeys_enabled, hotkeys, + Live, "Use the mod's configured keyboard and mouse shortcuts."); + BOOL("controls", "control", "hotkeys_extended", "Extended shortcut actions", hotkeys_extended, C::hotkeys_extended, + hotkeys, Live, "Enable the extended set of mod shortcut actions."); + BOOL("controls", "control", "use_scopely_hotkeys", "Use native game shortcuts", use_scopely_hotkeys, + C::use_scopely_hotkeys, hotkeys, Restart, + "The game uses its own shortcuts instead of the mod's shortcut bindings."); + Boolean(catalog, "community_mod.controls", "control", "queue_enabled", "Enable action queue", &Config::queue_enabled, + C::queue_enabled, hotkeys, Timing::Live, "Allow queued actions from the mod's queue shortcut.", + ResetQueueRecoveryTracking); + Boolean(catalog, "community_mod.controls", "control", "kirshara_fixes", "Kirshara queue protections", + &Config::kirshara_fixes, C::kirshara_fixes, always, Timing::Live, + "Protect and recover queued actions when targets or addresses change.", ResetQueueRecoveryTracking); + BOOL("controls", "control", "primary_requires_visible_target", "Require visible primary target", + primary_requires_visible_target, C::primary_requires_visible_target, hotkeys, Live, + "Require a visible selected target before issuing the primary-action shortcut."); + NUM("controls", "control", "select_timer", "Ship selection interval", select_timer, C::select_timer, hotkeys, Live, + "Maximum interval used by repeated ship-selection shortcuts.", "ms", 0, integer, 0, 1); + + NUM("camera", "graphics", "keyboard_zoom_speed", "Keyboard zoom speed", keyboard_zoom_speed, G::keyboard_zoom_speed, + zoom, Live, "Speed of keyboard zoom. Use Edit for a precise or custom value.", "distance/s", 0, largest, 1000, + 25); + NUM("camera", "graphics", "zoom", "System zoom-out distance", zoom, G::zoom, zoom, Live, + "Maximum system-camera zoom-out distance.", "distance", positive, largest, 10000, 250); + NUM("camera", "graphics", "haven_zoom", "Haven zoom-out distance", haven_zoom, G::haven_zoom, + Hook(&Config::installHavenZoomHooks), Live, "Maximum Haven camera distance. Zero uses the game's native limit.", + "distance", 0, largest, 6000, 250); + NUM("camera", "graphics", "default_system_zoom", "Default system zoom", default_system_zoom, G::default_system_zoom, + zoom, Live, "Camera distance used by reset/default zoom.", "distance", 0, largest, 6000, 50); + NUM("camera", "graphics", "transition_time", "Camera transition time", transition_time, G::transition_time, zoom, + Live, "Duration of camera zoom transitions.", "s", 0, largest, 2, .01f); + NUM("camera", "graphics", "system_pan_momentum_falloff", "Pan glide", system_pan_momentum_falloff, + G::system_pan_momentum_falloff, Hook(&Config::installPanHooks), Live, + "Retained motion per pan update. Higher values glide longer; values below 1 eventually stop.", "ratio", 0, + .999999f, .99f, .01f); + BOOL("camera", "graphics", "use_presets_as_default", "Use last preset as default zoom", use_presets_as_default, + G::use_presets_as_default, zoom, Live, "Selecting a zoom preset also updates the session's default distance."); + NUM("camera", "graphics", "system_zoom_preset_1", "Zoom preset 1", system_zoom_preset_1, G::system_zoom_preset_1, + zoom, Live, "Distance for zoom preset 1.", "distance", 0, largest, 6000, 50); + NUM("camera", "graphics", "system_zoom_preset_2", "Zoom preset 2", system_zoom_preset_2, G::system_zoom_preset_2, + zoom, Live, "Distance for zoom preset 2.", "distance", 0, largest, 6000, 50); + NUM("camera", "graphics", "system_zoom_preset_3", "Zoom preset 3", system_zoom_preset_3, G::system_zoom_preset_3, + zoom, Live, "Distance for zoom preset 3.", "distance", 0, largest, 6000, 50); + NUM("camera", "graphics", "system_zoom_preset_4", "Zoom preset 4", system_zoom_preset_4, G::system_zoom_preset_4, + zoom, Live, "Distance for zoom preset 4.", "distance", 0, largest, 6000, 50); + NUM("camera", "graphics", "system_zoom_preset_5", "Zoom preset 5", system_zoom_preset_5, G::system_zoom_preset_5, + zoom, Live, "Distance for zoom preset 5.", "distance", 0, largest, 6000, 50); + + NUM("interface", "graphics", "ui_scale", "Interface scale", ui_scale, G::ui_scale, scale, Live, + "Scale multiplier for the game interface. Zero uses native scaling.", "multiplier", 0, largest, 2, .05f); + NUM("interface", "graphics", "ui_scale_step", "Scale shortcut step", ui_scale_step, G::ui_scale_step, scale, Live, + "Amount added or removed by interface scale shortcuts.", "multiplier", positive, largest, .5f, .01f); + NUM("interface", "graphics", "ui_scale_ship", "Ship scale", ui_scale_ship, G::ui_scale_ship, scale, Live, + "Multiplier for ship models, including ships already loaded in the current view.", "multiplier", positive, + largest, 3, .05f); + NUM("interface", "graphics", "ui_scale_viewer", "Viewer scale", ui_scale_viewer, G::ui_scale_viewer, scale, + ReopenView, "Scale multiplier for ship and officer viewers. Reopen the viewer to refresh it.", "multiplier", + positive, largest, 3, .05f); + NUM("interface", "graphics", "fr_scale", "Planet backdrop scale", fr_scale, G::fr_scale, zoom, Restart, + "Multiplier for the system planet backdrop. Restart avoids rescaling an already modified transform.", + "multiplier", positive, largest, 4, .1f); + BOOL("interface", "graphics", "allow_cursor", "Allow cursor changes", allow_cursor, G::allow_cursor, scale, Live, + "Allow the game to update the mouse cursor."); +#if _WIN32 + BOOL("interface", "graphics", "free_resize", "Free window resizing", free_resize, G::free_resize, + Hook(&Config::installFreeResizeHooks), Restart, "Allow arbitrary window sizes on Windows."); + BOOL("interface", "graphics", "borderless_fullscreen", "Borderless fullscreen", borderless_fullscreen, + G::borderless_fullscreen, Hook(&Config::installFreeResizeHooks), Restart, + "Use a borderless fullscreen window on Windows."); +#endif + BOOL("interface", "ui", "disable_escape_exit", "Block Escape exit prompt", disable_escape_exit, + U::disable_escape_exit, hotkeys, Live, + "Prevent Escape from opening the game's exit prompt except for an enabled double-tap."); + NUM("interface", "ui", "disable_escape_exit_timer", "Escape double-tap interval", disable_escape_exit_timer, + U::disable_escape_exit_timer, hotkeys, Live, + "Zero disables double-tap. Otherwise two Escape presses within this interval may open the exit prompt.", "ms", 0, + integer, 0, 1); + BOOL("interface", "ui", "disable_move_keys", "Disable keyboard movement", disable_move_keys, U::disable_move_keys, + hotkeys, Live, "Disable map movement using configured movement shortcuts."); + BOOL("interface", "ui", "only_show_first_popup", "Show only the first startup popup", only_show_first_popup, + U::only_show_first_popup, misc, Live, "Suppress additional startup interstitial popups in this session."); + + BOOL("travel", "graphics", "galactic_anomaly_timer", "Galactic Anomaly timer", galactic_anomaly_timer, + G::galactic_anomaly_timer, Hook(&Config::installGalacticAnomalyTimerHooks), Live, + "Show the remaining lifetime on supported Galactic Anomalies."); + catalog.AddChoice("community_mod.travel", WarpModeSetting()); + + BOOL("previews", "ui", "disable_preview_locate", "Block Locate with an open preview", disable_preview_locate, + U::disable_preview_locate, hotkeys, Live, "Block the Locate shortcut while a preview panel is open."); + BOOL("previews", "ui", "disable_preview_recall", "Block Recall with an open preview", disable_preview_recall, + U::disable_preview_recall, hotkeys, Live, "Block the Recall shortcut while a preview panel is open."); + BOOL("previews", "ui", "show_cargo_default", "Automatically open cargo", show_cargo_default, U::show_cargo_default, + hotkeys, Live, "Automatically expand cargo when opening a supported target preview."); + BOOL("previews", "ui", "show_player_cargo", "Player cargo", show_player_cargo, U::show_player_cargo, hotkeys, Live, + "Automatically show cargo for player fleets."); + BOOL("previews", "ui", "show_station_cargo", "Station cargo", show_station_cargo, U::show_station_cargo, hotkeys, + Live, "Automatically show cargo for stations."); + BOOL("previews", "ui", "show_hostile_cargo", "Hostile cargo", show_hostile_cargo, U::show_hostile_cargo, hotkeys, + Live, "Automatically show cargo for hostiles."); + BOOL("previews", "ui", "show_armada_cargo", "Armada cargo", show_armada_cargo, U::show_armada_cargo, hotkeys, Live, + "Automatically show cargo for Armada targets."); + BOOL("previews", "ui", "instant_cargo_counter", "Instant ship cargo counter", instant_cargo_counter, + U::instant_cargo_counter, Hook(&Config::installInstantCargoCounterHooks), Live, + "Remove the animation delay from supported ship cargo counters."); + BOOL("previews", "ui", "format_cargo_values", "Format cargo values", format_cargo_values, U::format_cargo_values, + Hook(&Config::installCargoFormatHooks), Restart, + "Use the configured decimal precision for abbreviated cargo numbers."); + NUM("previews", "ui", "cargo_significant_decimals", "Cargo decimal places", cargo_significant_decimals, + U::cargo_significant_decimals, Hook(&Config::installCargoFormatHooks), ReopenView, + "Decimal places used for abbreviated cargo amounts, from 0 through 6.", "places", 0, 6, 0, 1); + + BOOL("claims", "ui", "auto_open_bulk_claim_flyout", "Automatically open bulk claims", auto_open_bulk_claim_flyout, + U::auto_open_bulk_claim_flyout, Hook(&Config::installGiftsBulkClaimHooks), Live, + "Open the bulk-claim drawer when entering supported claim screens."); + BOOL("claims", "ui", "daily_bulk_claim_toggle_default_on", "Daily bulk claims on by default", + daily_bulk_claim_toggle_default_on, U::daily_bulk_claim_toggle_default_on, + Hook(&Config::installDailyFactionBulkClaimHooks), Restart, + "Enable the daily faction Claim All toggle when first initialized in a session."); + BOOL("claims", "ui", "always_skip_reveal_sequence", "Skip reward reveal sequences", always_skip_reveal_sequence, + U::always_skip_reveal_sequence, misc, Live, "Skip the animated reward reveal and show the summary directly."); + catalog.AddBoolean("community_mod.claims", ForbiddenTechConfirmationSetting()); + BOOL("claims", "ui", "auto_confirm_reveal_ability", "Automatically confirm Excelsior reveal", + auto_confirm_reveal_ability, U::auto_confirm_reveal_ability, Hook(&Config::installRevealConfirmationHooks), Live, + "Skip confirmation for the Excelsior's Decloaking Deflector ability."); + BOOL("claims", "ui", "disable_exchange_all", "Hide artifact Exchange All", disable_exchange_all, + U::disable_exchange_all, Hook(&Config::installArtifactExchangeHooks), ReopenView, + "Hide Exchange All in the artifact popup. Reopen the popup after changing this."); + NUM("claims", "ui", "extend_chest_purchase_max", "Maximum chest quantity", extend_chest_purchase_max, + U::extend_chest_purchase_max, misc, ReopenView, + "Extended purchase/claim quantity limit in supported chest dialogs.", "chests", 1, integer, 0, 1); + BOOL("claims", "ui", "extend_donation_slider", "Extend donation slider", extend_donation_slider, + U::extend_donation_slider, misc, ReopenView, + "Use the extended donation quantity limit in supported donation dialogs."); + NUM("claims", "ui", "extend_donation_max", "Maximum donation quantity", extend_donation_max, U::extend_donation_max, + misc, ReopenView, "Extended donation quantity limit. Reopen the donation dialog to refresh it.", "donations", 1, + integer, 0, 1); + + BOOL("officers", "ui", "double_click_to_assign_ship", "Double-click to assign ship", double_click_to_assign_ship, + U::double_click_to_assign_ship, Hook(&Config::installDoubleClickAssignShipHooks), Live, + "Assign a ship with a double-click in supported selection screens."); + BOOL("officers", "ui", "officer_sort", "Below Deck Ability sorting", officer_sort, U::officer_sort, + Hook(&Config::installOfficerSortHooks), Restart, + "Add Below Deck Ability sorting to the officer roster and assignment screens."); + BOOL("officers", "ui", "arrow_keys_to_select_ship", "Arrow keys select ships", arrow_keys_to_select_ship, + U::arrow_keys_to_select_ship, hotkeys, Live, + "Use configured left/right controls to change ship selection in supported screens."); + BOOL("officers", "ui", "dock_syncmanagement", "Keep dock selection in sync", dock_syncmanagement, + U::dock_syncmanagement, hotkeys, Live, "Keep dock and Manage Ships selections aligned."); + BOOL("officers", "buffs", "use_out_of_dock_power", "Use out-of-dock power", use_out_of_dock_power, + B::use_out_of_dock_power, Hook(&Config::installBuffFixHooks), Live, + "Use deployed-ship power calculations for supported power displays."); + BOOL("officers", "ui", "reverse_haven_history", "Newest Haven help first", reverse_haven_history, + U::reverse_haven_history, Hook(&Config::installHavenHistoryHooks), ReopenView, + "Reverse Haven help history into newest-first order when the list refreshes."); + BOOL("interface", "ui", "focus_search", "Enable search-focus shortcut", focus_search, U::focus_search, hotkeys, Live, + "Allow the search-focus shortcut in supported game screens."); + + BOOL("chat", "ui", "show_player_title", "Show player titles", show_player_title, U::show_player_title, + Hook(&Config::installPlayerTitleDisplayHooks), ReopenView, + "Show title rows on chat messages. Reopen chat to rebuild existing rows."); + BOOL("chat", "ui", "disable_galaxy_chat", "Disable galaxy chat", disable_galaxy_chat, U::disable_galaxy_chat, + Hook(&Config::installChatPatches), Restart, "Disable galaxy chat tabs and preview messages."); + BOOL("chat", "ui", "disable_veil_chat", "Disable Veil chat", disable_veil_chat, U::disable_veil_chat, + Hook(&Config::installChatPatches), Restart, "Disable Veil chat tabs and preview messages where available."); + BOOL("notifications", "ui", "disable_toast_banners", "Hide toast banners", disable_toast_banners, + U::disable_toast_banners, toast, Live, + "Hide supported game toast banners; per-type exceptions can be configured below."); +#undef BOOL +#undef NUM + + struct Hud { + const char* name; + const char* label; + const char* defaultValue; + }; + for (const auto& hud : std::initializer_list{{"q_trials", "Q's Trials", U::hud_q_trials}, + {"field_training", "Field Training", U::hud_field_training}, + {"outposts", "Outposts", U::hud_outposts}, + {"missions", "Missions", U::hud_missions}}) { + const std::string key = std::string("hud_") + hud.name; + const std::string name = hud.name; + Choice( + catalog, "community_mod.hud", "ui", key.c_str(), hud.label, {"Auto", "Always", "Never"}, + {"auto", "always", "never"}, ChoiceDefault(hud.defaultValue, {"auto", "always", "never"}), + [name] { return static_cast(Config::Get().MissionHudButtonVisibility(name)); }, + [name](int value) { + if (!mission_hud::CanChange()) + return false; + Config::Get().mission_hud_buttons[name] = static_cast(value); + mission_hud::Refresh(); + return true; + }, + [] { return mission_hud::Available(); }, Timing::Live, "Choose when this mission HUD button is visible."); + } + for (const bool player : {true, false}) { + auto profile = [player]() -> FleetLabelProfile& { + return player ? Config::Get().zoom_label_player : Config::Get().zoom_label_non_player; + }; + Choice( + catalog, "community_mod.labels", "graphics", + player ? "zoom_label_player_detail" : "zoom_label_non_player_detail", + player ? "Player label detail" : "Non-player label detail", {"Native", "Expanded", "Compact", "Threshold"}, + {"native", "expanded", "compact", "threshold"}, + ChoiceDefault(player ? G::zoom_label_player_detail : G::zoom_label_non_player_detail, + {"native", "expanded", "compact", "threshold"}), + [profile] { return static_cast(profile().detail); }, + [profile](int value) { + profile().detail = static_cast(value); + RefreshFleetLabelControls(); + return true; + }, + fleetLabels, Timing::Live, "Choose the amount of information shown on system fleet labels."); + auto definition = MakeDefinition( + "graphics", player ? "zoom_label_player_threshold" : "zoom_label_non_player_threshold", + player ? "Player detail threshold" : "Non-player detail threshold", + static_cast(player ? G::zoom_label_player_threshold : G::zoom_label_non_player_threshold), + [profile] { return profile().zoom_threshold; }, + [profile](float value) { + if (!std::isfinite(value) || value < 0 || value > 1) + return false; + profile().zoom_threshold = value; + RefreshFleetLabelControls(); + return true; + }, + fleetLabels, Timing::Live, + "Zoom threshold used by Threshold label detail. Enter a ratio from 0 through 1: zero stays compact; one stays " + "expanded.", + "ratio"); + auto exact = Keep>(definition); + ExactEntry(catalog, "community_mod.labels", exact, + [](std::string_view text) { return ParseNumber(text, 0, 1); }); + auto slider = Keep(std::move(definition), 0.f, 1.f, .01f, [profile, fleetLabels] { + return fleetLabels() && profile().detail == FleetLabelDetail::Threshold; + }); + catalog.AddSlider("community_mod.labels", *slider); + } + const std::pair alerts[]{ + {"alert_victory", &Config::alert_victory}, + {"alert_defeat", &Config::alert_defeat}, + {"alert_armada_created", &Config::alert_armada_created}, + {"alert_armada_battle_won", &Config::alert_armada_battle_won}, + {"alert_armada_battle_lost", &Config::alert_armada_battle_lost}}; + const char* alertLabels[]{"Victory sound", "Defeat sound", "Armada created sound", "Armada victory sound", + "Armada defeat sound"}; + const char* alertDefaults[]{DefaultConfig::Audio::alert_victory, DefaultConfig::Audio::alert_defeat, + DefaultConfig::Audio::alert_armada_created, DefaultConfig::Audio::alert_armada_battle_won, + DefaultConfig::Audio::alert_armada_battle_lost}; + for (std::size_t i = 0; i < std::size(alerts); ++i) { + const auto member = alerts[i].second; + std::vector labels, values; + for (int value = 0; value < static_cast(NotificationSound::Count); ++value) { + auto text = std::string(notification_sound_name(static_cast(value))); + values.push_back(text); + labels.push_back(value == 0 ? "Off" : text); + } + Choice( + catalog, "community_mod.notifications", "audio", alerts[i].first, alertLabels[i], std::move(labels), + std::move(values), + static_cast(notification_sound_from_name(alertDefaults[i]).value_or(NotificationSound::None)), + [member] { return static_cast(Config::Get().*member); }, + [member](int value) { + Config::Get().*member = static_cast(value); + return true; + }, + toast, Timing::Live, "Choose the built-in sound for this notification."); + auto preview = Keep(); + preview->identity = Id("audio", alerts[i].first) + ".preview"; + preview->label = alertLabels[i]; + preview->read = [member, toast, label = alertLabels[i]](std::size_t) { + return ActionSetting::Presentation{label, "Play", "", + toast() && Config::Get().*member != NotificationSound::None}; + }; + preview->invoke = [member](std::size_t) { notification_audio_play(Config::Get().*member); }; + catalog.AddAction("community_mod.notifications", *preview); + } + + const auto loading = Hook(&Config::installLoadingScreenHooks); + const auto transition = Hook(&Config::installTransitionScreenHooks); + Boolean(catalog, "community_mod.loading", "graphics", "loader_enabled", "Custom loading background", + &Config::loader_enabled, G::loader_enabled, loading, Timing::Restart, + "Replace the login background with the configured image."); + Boolean(catalog, "community_mod.loading", "graphics", "loader_transition", "Custom transition background", + &Config::loader_transition, G::loader_transition, transition, Timing::Restart, + "Use the configured background during supported screen transitions."); + Boolean(catalog, "community_mod.loading", "graphics", "loader_transition_black", "Black transition background", + &Config::loader_transition_black, G::loader_transition_black, transition, Timing::Restart, + "Use black instead of the configured image during transitions."); + Boolean(catalog, "community_mod.loading", "graphics", "loader_tip_enabled", "Loading tips", + &Config::loader_tip_enabled, G::loader_tip_enabled, Hook(&Config::installLoadingTipHooks), Timing::Restart, + "Show custom tips on supported loading screens."); + Number(catalog, "community_mod.loading", "graphics", "loader_logo_scale", "Loading logo scale", + &Config::loader_logo_scale, static_cast(G::loader_logo_scale), loading, Timing::Restart, + "Multiplier for the loading-screen logo.", "multiplier", positive, largest, 3, .1f); + auto image = Keep>(MakeDefinition( + "graphics", "loader_image", "Loading image path", G::loader_image, [] { return Config::Get().loader_image; }, + [](const std::string& value) { + Config::Get().loader_image = value; + return true; + }, + loading, Timing::Restart, + "Path to an image readable by the game. Empty uses the embedded fallback. This does not move or copy the " + "image.")); + ExactEntry(catalog, "community_mod.loading", image, [](std::string_view value) -> std::optional { + value = Trim(value); + if (value.empty()) + return std::string{}; + std::error_code error; + return std::filesystem::is_regular_file(std::filesystem::u8path(value), error) && !error + ? std::optional{std::string(value)} + : std::nullopt; + }); + Lists(catalog); + Information(catalog, "community_mod.advanced", "community_mod.configuration.path", "Configuration file", [] { + const auto path = File::MakePath(File::Config()); + return std::string(reinterpret_cast(path.data()), path.size()); + }); + Information(catalog, "community_mod.advanced", "community_mod.configuration.saving", "Saving settings", [] { + return "Changes apply immediately unless marked for reopening or restart. Saves preserve unrelated TOML values; a " + "conflict or failed save remains visible until resolved."; + }); + Information(catalog, "community_mod.advanced", "community_mod.configuration.sync", "Data sync", [] { + return "Sync targets, credentials, connection settings and sync diagnostics remain in TOML. Restart after editing " + "them. Credentials are never displayed here."; + }); + Information(catalog, "community_mod.advanced", "community_mod.configuration.audio", "Audio diagnostics", [] { + return "Advanced audio event tracing and the arbitrary event-name mute list remain in TOML: audio.trace_events and " + "audio.disabled_events."; + }); + Information(catalog, "community_mod.advanced", "community_mod.configuration.patches", "Compatibility switches", [] { + return "Emergency hook-install switches under [patches] require restart. Keep them enabled unless diagnosing " + "compatibility with another mod."; + }); + Information(catalog, "community_mod.advanced", "community_mod.configuration.endpoints", "Configuration sources", [] { + return "Advanced configuration and asset endpoint overrides remain in TOML under [config]. Restart after editing " + "them."; + }); + Information(catalog, "community_mod.advanced", "community_mod.configuration.experimental", + "Legacy experimental options", [] { + return "control.enable_experimental and graphics.system_pan_momentum remain compatibility inputs in " + "TOML. The current mod does not use them to change gameplay. Pan glide is controlled by " + "system_pan_momentum_falloff."; + }); +#if !defined(_WIN32) + Information(catalog, "community_mod.advanced", "community_mod.configuration.windows", "Windows window options", [] { + return "graphics.free_resize and graphics.borderless_fullscreen apply only to the Windows client."; + }); +#endif +} +} // namespace mod_settings diff --git a/mods/src/settings/upstream_settings.h b/mods/src/settings/upstream_settings.h new file mode 100644 index 000000000..1343f7249 --- /dev/null +++ b/mods/src/settings/upstream_settings.h @@ -0,0 +1,13 @@ +#pragma once + +#include "page_catalog.h" + +namespace mod_settings +{ +// Register only features present in the upstream mod. The catalog owns its +// adapters for the process lifetime; browsing it never changes configuration. +void RegisterUpstreamSettings(PageCatalog& catalog); +// Save changes made by an existing shortcut or native pin action through the +// same field serializer. Returns false for unknown or unavailable controls. +bool PersistCurrentSetting(std::string_view section, std::string_view key); +} // namespace mod_settings diff --git a/mods/src/settings/value_settings.h b/mods/src/settings/value_settings.h new file mode 100644 index 000000000..bfa8da1bd --- /dev/null +++ b/mods/src/settings/value_settings.h @@ -0,0 +1,337 @@ +#pragma once + +#include "setting_metadata.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mod_settings +{ +enum class Availability { Known, Unavailable, Unsupported }; +enum class UnavailableReason { Retry, OutsideRange, InvalidValue }; +template struct ValueReadResult { + Availability availability = Availability::Unavailable; + std::optional value; + std::uint64_t generation = 0; + UnavailableReason reason = UnavailableReason::Retry; + static ValueReadResult Known(T value, std::uint64_t generation) + { return {Availability::Known, value, generation}; } + bool known() const + { return availability == Availability::Known && value.has_value() && generation != 0; } +}; +enum class ApplyResult { Applied, Rejected, Unverified }; +enum class Outcome { AppliedVerified, Unchanged, Suppressed, Busy, Conflict, Rejected, Unverified }; +template struct ValueDefinition { + std::string id; + std::string label; + std::function()> read; + // Adapter must revalidate its own generation immediately before mutation. + std::function write; + SettingMetadata metadata; + std::optional default_value; + // Called only for an explicit, verified user intent, never by Observe/render. + std::function persist; + std::function save_status; + // Restart-only adapters expose their still-active value independently from + // the requested value returned by read. + std::function()> active_value; +}; +template struct ValueSnapshot { + ValueReadResult state; + std::uint64_t revision = 0; + std::uint64_t epoch = 0; + std::uint64_t owner = 0; +}; +template struct ValueWriteResult { + Outcome outcome; + ValueSnapshot snapshot; +}; + +// All calls, including scope destruction, belong to the constructing UI thread. +// No timers, background work, locks, storage, or IL2CPP dependencies. +template class ValueSetting +{ +public: + using ReadResult = ValueReadResult; + using Definition = ValueDefinition; + using Snapshot = ValueSnapshot; + using WriteResult = ValueWriteResult; + explicit ValueSetting(Definition definition) + : definition_(std::move(definition)) + { + } + ValueSetting(const ValueSetting&) = delete; + ValueSetting& operator=(const ValueSetting&) = delete; + const std::string& id() const + { return definition_.id; } + const std::string& label() const + { return definition_.label; } + const SettingMetadata& metadata() const + { return definition_.metadata; } + const std::optional& default_value() const + { return definition_.default_value; } + SaveStatus save_status() const + { + CheckThread(); + if (persistence_failed_) + return {SaveState::Unavailable}; + try { + return definition_.save_status ? definition_.save_status() : SaveStatus{}; + } catch (...) { + return {SaveState::Unavailable}; + } + } + bool restart_pending() + { + CheckThread(); + if (metadata().timing != ApplyTiming::Restart || !definition_.active_value) + return false; + try { + const auto requested = Observe(); + const auto active = definition_.active_value(); + return requested.state.known() && active && requested.state.value != active; + } catch (...) { + return false; + } + } + std::string status_text() + { + CheckThread(); + // A historical save completion must never imply that an unavailable live + // setting was just applied. Observation remains read-only. + if (!Observe().state.known()) + return {}; + const auto status = save_status(); + const bool restart = metadata().timing == ApplyTiming::Restart; + const bool pending_restart = restart_pending(); + std::string text; + switch (status.state) { + case SaveState::Pending: + text = restart ? "Saving..." : "Applied; saving..."; + break; + case SaveState::Saved: + text = restart ? "Saved" : "Applied and saved"; + break; + case SaveState::Conflict: + text = restart ? "Not saved; file changed externally" : "Applied; file changed externally"; + break; + case SaveState::Failed: + text = restart ? "Saving failed" : "Applied; saving failed"; + break; + case SaveState::Unavailable: + text = restart ? "Saving unavailable" : "Applied for this session; saving unavailable"; + break; + default: + break; + } + if (pending_restart) + text += text.empty() ? "Restart required" : "; restart required"; + else if (metadata().timing == ApplyTiming::ReopenView && status.state != SaveState::None) + text += "; reopen the affected view"; + return text; + } + + // One process-lifetime presentation adapter. Notifications are synchronous, + // bounded and run under the write reentry guard; rendering cannot write back. + bool SetChangeObserver(void (*observer)()) + { + CheckThread(); + if (change_observer_ && change_observer_ != observer) + return false; + change_observer_ = observer; + return true; + } + + class RenderScope + { + public: + explicit RenderScope(ValueSetting& setting) + : setting_(setting) + { + setting_.CheckThread(); + ++setting_.render_depth_; + } + ~RenderScope() + { --setting_.render_depth_; } + RenderScope(const RenderScope&) = delete; + RenderScope& operator=(const RenderScope&) = delete; + + private: + ValueSetting& setting_; + }; + + Snapshot Observe() + { + CheckThread(); + if (observing_) + return {{}, current_.revision, epoch_, identity_}; + struct Observing { + bool& flag; + Observing(bool& flag) + : flag(flag) + { flag = true; } + ~Observing() + { flag = false; } + } guard(observing_); + const auto read_epoch = epoch_; + ReadResult next; + try { + next = definition_.read(); + } catch (...) { + next = {}; + } + if (read_epoch != epoch_) + next = {}; + if (!next.known()) { + next.value.reset(); + next.generation = 0; + if (next.availability == Availability::Known) + next.availability = Availability::Unavailable; + } + if (next.availability != current_.state.availability || next.value != current_.state.value + || next.generation != current_.state.generation || next.reason != current_.state.reason) + ++current_.revision; + current_.state = next; + current_.epoch = epoch_; + current_.owner = identity_; + return current_; + } + bool IsCurrent(const Snapshot& observed) + { + CheckThread(); + if (render_depth_ || applying_) + return false; + return Matches(observed, Observe()); + } + + void InvalidateSession() + { + CheckThread(); + ++epoch_; + current_ = {{}, current_.revision + 1, epoch_, identity_}; + } + + WriteResult SetFromUser(T desired, const Snapshot& observed) + { + CheckThread(); + if (render_depth_) + return {Outcome::Suppressed, current_}; + if (applying_) + return {Outcome::Busy, current_}; + // Cover read callbacks as well as the setter: neither can reenter application. + struct Applying { + bool& flag; + Applying(bool& flag) + : flag(flag) + { flag = true; } + ~Applying() + { flag = false; } + } guard(applying_); + const auto before = Observe(); + if (!before.state.known()) + return {Outcome::Rejected, before}; + if (!Matches(observed, before)) + return {Outcome::Conflict, before}; + if (*before.state.value == desired) { + Persist(desired); + NotifyChanged(); + if (epoch_ != before.epoch) + return {Outcome::Unverified, current_}; + return {Outcome::Unchanged, before}; + } + ApplyResult applied = ApplyResult::Unverified; + try { + applied = definition_.write(desired, before.state.generation); + } catch (...) { + } + if (epoch_ != before.epoch) + return {Outcome::Unverified, current_}; + const auto after = Observe(); + if (!after.state.known() || after.state.generation != before.state.generation) + return {Outcome::Unverified, after}; + if (applied == ApplyResult::Applied && *after.state.value == desired) { + Persist(*after.state.value); + NotifyChanged(); + if (epoch_ != before.epoch) + return {Outcome::Unverified, current_}; + return {Outcome::AppliedVerified, after}; + } + // Preserve the authoritative readback even when the adapter reports failure. + return {applied == ApplyResult::Rejected ? Outcome::Rejected : Outcome::Unverified, after}; + } + + WriteResult Reset(const Snapshot& observed) + { + CheckThread(); + return definition_.default_value ? SetFromUser(*definition_.default_value, observed) + : WriteResult{Outcome::Rejected, current_}; + } + WriteResult RetrySave(const Snapshot& observed) + { + CheckThread(); + if (!definition_.persist || !observed.state.known()) + return {Outcome::Rejected, current_}; + return SetFromUser(*observed.state.value, observed); + } + +private: + bool Matches(const Snapshot& observed, const Snapshot& before) const + { + return observed.owner == identity_ && observed.state.known() && before.state.known() + && observed.epoch == before.epoch && observed.revision == before.revision + && observed.state.generation == before.state.generation && observed.state.value == before.state.value; + } + void Persist(const T& value) + { + try { + if (definition_.persist) + definition_.persist(value); + persistence_failed_ = false; + } catch (...) { + persistence_failed_ = true; + } + } + void NotifyChanged() + { + // Presentation failure does not undo or misreport an authoritative write. + try { + if (change_observer_) + change_observer_(); + } catch (...) { + } + } + void CheckThread() const + { + if (std::this_thread::get_id() != thread_) + throw std::logic_error("setting thread mismatch"); + } + Definition definition_; + inline static std::atomic next_identity_{1}; + const std::uint64_t identity_ = next_identity_.fetch_add(1); + const std::thread::id thread_ = std::this_thread::get_id(); + Snapshot current_; + std::uint64_t epoch_ = 1; + unsigned render_depth_ = 0; + bool applying_ = false; + bool observing_ = false; + bool persistence_failed_ = false; + void (*change_observer_)() = nullptr; +}; + +template PreparedSettingAction PrepareReset(ValueSetting& setting) +{ + const auto snapshot = setting.Observe(); + return {[&setting, snapshot] { return setting.default_value().has_value() && setting.IsCurrent(snapshot); }, + [&setting, snapshot] { + const auto result = setting.Reset(snapshot).outcome; + return result == Outcome::AppliedVerified || result == Outcome::Unchanged; + }}; +} + +} // namespace mod_settings diff --git a/mods/src/settings/value_view.h b/mods/src/settings/value_view.h new file mode 100644 index 000000000..6461f7f4d --- /dev/null +++ b/mods/src/settings/value_view.h @@ -0,0 +1,90 @@ +#pragma once + +#include "value_settings.h" + +namespace mod_settings +{ +// One instance per live view, never a persisted copy of the preference. Native +// adapters own rendering and invalidate this view before its context is released. +template class ValueView +{ +public: + using Snapshot = ValueSnapshot; + using WriteResult = ValueWriteResult; + explicit ValueView(ValueSetting& setting) + : setting_(setting) + { + } + + void Bind() + { + snapshot_ = setting_.Observe(); + unresolved_ = !snapshot_.state.known(); + failed_ = false; + bound_ = true; + } + void Unbind() + { + snapshot_ = {}; + bound_ = false; + unresolved_ = true; + failed_ = false; + } + void Invalidate() + { + snapshot_ = {}; + unresolved_ = true; + failed_ = false; + } + WriteResult Request(T desired) + { + if (!editable()) + return {Outcome::Rejected, snapshot_}; + auto result = setting_.SetFromUser(desired, snapshot_); + if (result.outcome == Outcome::Suppressed || result.outcome == Outcome::Busy) + return result; + snapshot_ = result.snapshot; + failed_ = result.outcome != Outcome::AppliedVerified && result.outcome != Outcome::Unchanged; + unresolved_ = !snapshot_.state.known() || result.outcome == Outcome::Unverified; + return result; + } + WriteResult Reset() + { + if (!editable()) + return {Outcome::Rejected, snapshot_}; + return Accept(setting_.Reset(snapshot_)); + } + WriteResult RetrySave() + { + if (!editable()) + return {Outcome::Rejected, snapshot_}; + return Accept(setting_.RetrySave(snapshot_)); + } + bool editable() const + { return bound_ && !unresolved_ && snapshot_.state.known(); } + bool failed() const + { return failed_; } + UnavailableReason unavailableReason() const + { return snapshot_.state.reason; } + std::optional value() const + { return editable() ? snapshot_.state.value : std::nullopt; } + ValueSetting& setting() const + { return setting_; } + +private: + WriteResult Accept(WriteResult result) + { + if (result.outcome != Outcome::Suppressed && result.outcome != Outcome::Busy) { + snapshot_ = result.snapshot; + failed_ = result.outcome != Outcome::AppliedVerified && result.outcome != Outcome::Unchanged; + unresolved_ = !snapshot_.state.known() || result.outcome == Outcome::Unverified; + } + return result; + } + ValueSetting& setting_; + Snapshot snapshot_; + bool bound_ = false; + bool unresolved_ = true; + bool failed_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/warp_mode.cc b/mods/src/settings/warp_mode.cc new file mode 100644 index 000000000..ffeb4a2b1 --- /dev/null +++ b/mods/src/settings/warp_mode.cc @@ -0,0 +1,62 @@ +#include "warp_mode.h" +#include "config.h" +#include "defaultconfig.h" +#include "persisted_setting.h" +#include +#include +#include + +namespace mod_settings +{ +namespace +{ + constexpr std::array modes{"none", "warp", "jump"}; + constexpr int DefaultMode() + { + for (std::size_t i = 0; i < modes.size(); ++i) + if (std::string_view(modes[i]) == DefaultConfig::UI::auto_confirm_instant_warp) + return static_cast(i); + return -1; + } + static_assert(DefaultMode() >= 0); +} // namespace +ChoiceSetting& WarpModeSetting() +{ + static ChoiceSetting setting( + PersistedDefinition( + {"community_mod.warp_mode", "Instant warp mode", + [] { + return Config::Get().installInstantWarpConfirmationHooks + ? ValueReadResult::Known(static_cast(Config::Get().auto_confirm_instant_warp), 1) + : ValueReadResult{Availability::Unsupported}; + }, + [](int value, std::uint64_t generation) { + if (generation != 1 || !Config::Get().installInstantWarpConfirmationHooks || value < 0 + || value >= static_cast(modes.size())) + return ApplyResult::Rejected; + Config::Get().auto_confirm_instant_warp = static_cast(value); + spdlog::info("Auto-confirm instant warp set to {}", modes[value]); + return ApplyResult::Applied; + }}, + {"ui", "auto_confirm_instant_warp", + "Choose which instant-travel action is confirmed automatically. Ship filters can override this mode.", "", + ApplyTiming::Live}, + DefaultMode(), [](const int& value) -> config_edit::Value { return std::string(modes.at(value)); }), + {"Ask", "Warp", "Jump"}); + return setting; +} + +bool SetWarpMode(InstantWarpConfirmation desired) +{ + auto& setting = WarpModeSetting().state(); + const auto result = setting.SetFromUser(static_cast(desired), setting.Observe()); + return result.outcome == Outcome::AppliedVerified || result.outcome == Outcome::Unchanged; +} +void CycleWarpMode() +{ + auto& setting = WarpModeSetting().state(); + const auto snapshot = setting.Observe(); + if (snapshot.state.known()) + setting.SetFromUser((*snapshot.state.value + 1) % 3, snapshot); +} +} // namespace mod_settings diff --git a/mods/src/settings/warp_mode.h b/mods/src/settings/warp_mode.h new file mode 100644 index 000000000..175a540be --- /dev/null +++ b/mods/src/settings/warp_mode.h @@ -0,0 +1,14 @@ +#pragma once +#include "choice_setting.h" + +enum class InstantWarpConfirmation; + +namespace mod_settings +{ +// Shared mutation path for the shortcut and the forthcoming selection control. +// Changes live behavior immediately; persistence remains best-effort and reports +// failures through the existing runtime writer. Call on the game UI thread. +bool SetWarpMode(InstantWarpConfirmation desired); +void CycleWarpMode(); +ChoiceSetting& WarpModeSetting(); +} // namespace mod_settings diff --git a/mods/src/toml_editor.cc b/mods/src/toml_editor.cc new file mode 100644 index 000000000..73d876207 --- /dev/null +++ b/mods/src/toml_editor.cc @@ -0,0 +1,85 @@ +#include "toml_editor.h" +#include "config_save.h" +#include "toml_source_editor.h" + +#include +#include + +namespace config_edit +{ +namespace +{ + std::string Encode(const Value& value) + { + if (const auto* text = std::get_if(&value)) { + try { + source::ValidateUtf8(*text); + } catch (const source::Failure&) { + throw std::invalid_argument("invalid desired UTF-8 string"); + } + } + return std::visit([](const auto& item) { return source::Render(toml::value(item)); }, value); + } + std::optional ReadValue(const toml::node* node) + { + if (!node) + return std::nullopt; + if (node->is_boolean()) + return Value{node->as_boolean()->get()}; + if (node->is_string()) + return Value{node->as_string()->get()}; + if (node->is_floating_point()) + return Value{node->as_floating_point()->get()}; + if (node->is_integer()) + return Value{node->as_integer()->get()}; + throw std::invalid_argument("unsupported setting type"); + } +} // namespace +Prepared TomlEditor::Prepare(const std::string& text, const Request& request) +{ + try { + if (const auto* value = std::get_if(&request.desired); value && !std::isfinite(*value)) + return {Outcome::Unsupported, {}}; + if (!cached_table_ || cached_text_ != text) { + auto parsed = source::Parse(text); + cached_table_.reset(); // Never pair new bytes with stale regions if allocation fails. + cached_text_ = text; + cached_table_ = std::move(parsed); + } + const auto& document = *cached_table_; + const auto* parent = document.get_as(request.section); + if (document.contains(request.section) && !parent) + return {Outcome::Unsupported, {}}; + const auto current = ReadValue(parent ? parent->get(request.key) : nullptr); + if (current && *current == request.desired) + return {Outcome::AlreadySaved, {}}; + if (current != request.expected) + return {Outcome::Conflict, {}}; + auto result = source::SetValue(text, {request.section, request.key}, Encode(request.desired)); + return {Outcome::Prepared, std::move(result)}; + } catch (const source::Failure& error) { + const std::string_view code(error.what()); + return {code == "InvalidDocument" || code == "InvalidUtf8" || code == "DuplicateTarget" ? Outcome::InvalidDocument + : Outcome::Unsupported, + {}}; + } catch (const std::invalid_argument&) { + return {Outcome::Unsupported, {}}; + } +} +Outcome TomlEditor::Save(const std::filesystem::path& path, const Request& request) +{ + try { + const auto original = ReadConfigText(path); + auto edit = Prepare(original, request); + if (edit.outcome != Outcome::Prepared) + return edit.outcome; + if (!ReplaceConfigText(path, edit.text, original)) + return Outcome::Conflict; + cached_table_.reset(); + cached_text_.clear(); + return Outcome::Saved; + } catch (const std::exception&) { + return Outcome::IoError; + } +} +} // namespace config_edit diff --git a/mods/src/toml_editor.h b/mods/src/toml_editor.h new file mode 100644 index 000000000..2519b7584 --- /dev/null +++ b/mods/src/toml_editor.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace config_edit +{ +using Value = std::variant; +struct Request { + std::string section, key; + std::optional expected; // Missing is distinct from a configured default. + Value desired; +}; +enum class Outcome { Prepared, Saved, AlreadySaved, Conflict, InvalidDocument, Unsupported, IoError, Cancelled }; +struct Prepared { + Outcome outcome; + std::string text; // Nonempty only when an edit has been prepared. +}; + +// Own on a single worker. Cache represents disk text, not live game configuration. +class TomlEditor +{ +public: + Prepared Prepare(const std::string& text, const Request& request); + Outcome Save(const std::filesystem::path& path, const Request& request); + +private: + std::string cached_text_; + std::optional cached_table_; +}; +} // namespace config_edit diff --git a/mods/src/toml_source_editor.h b/mods/src/toml_source_editor.h new file mode 100644 index 000000000..0591b6203 --- /dev/null +++ b/mods/src/toml_source_editor.h @@ -0,0 +1,512 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// This source-only core owns no files, processes, game state or network access. +namespace config_edit::source +{ +using Path = std::vector; +struct Failure : std::runtime_error { + std::optional line; + explicit Failure(const char* code, std::optional at = {}) + : std::runtime_error(code) + , line(at) + { + } +}; +inline void ValidateUtf8(std::string_view text) +{ + for (std::size_t i = 0; i < text.size();) { + const auto first = static_cast(text[i++]); + if (first < 0x80) + continue; + unsigned count; + std::uint32_t code; + if (first >= 0xc2 && first <= 0xdf) { + count = 1; + code = first & 0x1f; + } else if (first >= 0xe0 && first <= 0xef) { + count = 2; + code = first & 0x0f; + } else if (first >= 0xf0 && first <= 0xf4) { + count = 3; + code = first & 7; + } else + throw Failure("InvalidUtf8"); + if (i + count > text.size()) + throw Failure("InvalidUtf8"); + for (unsigned n = 0; n < count; ++n) { + const auto next = static_cast(text[i++]); + if ((next & 0xc0) != 0x80) + throw Failure("InvalidUtf8"); + code = (code << 6) | (next & 0x3f); + } + if ((count == 1 && code < 0x80) || (count == 2 && code < 0x800) || (count == 3 && code < 0x10000) || code > 0x10ffff + || (code >= 0xd800 && code <= 0xdfff)) + throw Failure("InvalidUtf8"); + } +} +inline toml::table Parse(std::string_view text) +{ + ValidateUtf8(text); + try { + return toml::parse(text); + } catch (const toml::parse_error& error) { + const auto description = error.description(); + const auto duplicate = description.find("redefine") != std::string_view::npos + || description.find("duplicate") != std::string_view::npos; + throw Failure(duplicate ? "DuplicateTarget" : "InvalidDocument", error.source().begin.line); + } +} +inline std::string Render(const toml::node& value) +{ + std::ostringstream output; + output.exceptions(std::ios::badbit | std::ios::failbit); + output << toml::toml_formatter(value, toml::format_flags::none); + return output.str(); +} +inline toml::table ValueDocument(std::string_view value) +{ + try { + auto result = Parse(std::string("__stfc_value__ = ") + std::string(value)); + if (result.size() != 1 || !result.contains("__stfc_value__")) + throw Failure("InvalidValue"); + return result; + } catch (const Failure&) { + throw Failure("InvalidValue"); + } +} +inline std::string EncodeKey(std::string_view value) +{ + const bool bare = !value.empty() && std::all_of(value.begin(), value.end(), [](unsigned char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_'; + }); + return bare ? std::string(value) : Render(toml::value(std::string(value))); +} +inline std::string CanonicalPath(const Path& path) +{ + std::string result; + for (const auto& part : path) { + if (!result.empty()) + result += '.'; + result += EncodeKey(part); + } + return result; +} +inline bool Prefix(const Path& prefix, const Path& path) +{ return prefix.size() <= path.size() && std::equal(prefix.begin(), prefix.end(), path.begin()); } +inline Path Append(Path prefix, const Path& suffix) +{ + prefix.insert(prefix.end(), suffix.begin(), suffix.end()); + return prefix; +} +inline Path Relative(const Path& path, std::size_t prefix) +{ return Path(path.begin() + prefix, path.end()); } +inline void CheckPath(const Path& path) +{ + if (path.empty()) + throw Failure("InvalidPath"); + for (const auto& part : path) + ValidateUtf8(part); +} + +namespace detail +{ + struct Patch { + std::size_t begin, end; + std::string replacement; + }; + struct Header { + Path path; + std::size_t begin, end, key_begin, key_end; + bool array; + }; + struct Assignment { + Path path, context; + std::size_t begin, end, key_begin, key_end, value_begin, value_end; + }; + struct Field { + Path path; + std::size_t key_begin, key_end, value_begin, value_end; + }; + struct Inline { + Path path; + std::size_t begin, end; + std::vector fields; + }; + + class Document + { + struct Utf8Adjustment { + std::size_t line_start; + std::uint32_t column_after; + std::size_t extra_bytes; + }; + std::vector utf8_adjustments_; + + public: + std::string text; + toml::table tree; + std::vector
headers; + std::vector assignments; + std::vector inlines; + std::map value_ends; + std::vector lines; + + explicit Document(std::string_view source) + : text(source) + , tree(Parse(source)) + { + IndexCoordinates(); + + CollectRegions(tree); + Scan(); + } + std::size_t Bom() const + { return text.starts_with("\xef\xbb\xbf") ? 3 : 0; } + std::size_t Offset(toml::source_position position) const + { + if (!position.line || position.line > lines.size() || !position.column) + throw Failure("UnsupportedTarget"); + const auto line_start = lines[position.line - 1]; + const auto coordinate = std::pair{line_start, static_cast(position.column)}; + const auto next = std::upper_bound(utf8_adjustments_.begin(), utf8_adjustments_.end(), coordinate, + [](const auto& requested, const Utf8Adjustment& adjustment) { + return requested.first < adjustment.line_start + || (requested.first == adjustment.line_start + && requested.second < adjustment.column_after); + }); + const auto extra = next != utf8_adjustments_.begin() && std::prev(next)->line_start == line_start + ? std::prev(next)->extra_bytes + : 0; + const auto offset = line_start + static_cast(position.column) - 1 + extra; + const auto line_end = position.line < lines.size() ? lines[position.line] - 1 : text.size(); + if (offset > line_end) + throw Failure("UnsupportedTarget"); + return offset; + } + std::uint32_t Line(std::size_t offset) const + { return static_cast(std::upper_bound(lines.begin(), lines.end(), offset) - lines.begin()); } + bool PhysicalInline(const toml::node& node) const + { + if (!node.is_table() || !node.as_table()->is_inline()) + return false; + const auto begin = Offset(node.source().begin), end = Offset(node.source().end); + return begin < end && end <= text.size() && text[begin] == '{' && text[end - 1] == '}'; + } + bool ArrayOfTables(const toml::node& node) const + { + if (!node.is_array_of_tables()) + return false; + const auto begin = Offset(node.source().begin); + return begin + 1 < text.size() && text[begin] == '[' && text[begin + 1] == '['; + } + const toml::node* Find(const Path& path) const + { + const toml::node* current = &tree; + for (const auto& part : path) { + if (!current->is_table()) + throw Failure("UnsupportedTarget"); + current = current->as_table()->get(part); + if (!current) + return nullptr; + } + return current; + } + std::string Newline() const + { return text.find("\r\n") != std::string::npos ? "\r\n" : "\n"; } + bool Accepts(const std::string& candidate, const toml::table& desired) const + { + try { + return Parse(candidate) == desired; + } catch (const Failure&) { + return false; + } + } + std::optional Apply(std::vector patches, const toml::table& desired) const + { + std::sort(patches.begin(), patches.end(), [](const Patch& a, const Patch& b) { return a.begin > b.begin; }); + std::size_t boundary = text.size(); + auto candidate = text; + for (const auto& patch : patches) { + if (patch.end < patch.begin || patch.end > boundary) + return {}; + candidate.replace(patch.begin, patch.end - patch.begin, patch.replacement); + boundary = patch.begin; + } + if (Accepts(candidate, desired)) + return candidate; + return {}; + } + + private: + void IndexCoordinates() + { + auto line_start = Bom(); + lines.push_back(line_start); + std::uint32_t column = 1; + std::size_t extra_bytes = 0; + for (auto offset = line_start; offset < text.size();) { + if (text[offset] == '\n') { + lines.push_back(++offset); + line_start = offset; + column = 1; + extra_bytes = 0; + continue; + } + const auto begin = offset++; + while (offset < text.size() && (static_cast(text[offset]) & 0xc0) == 0x80) + ++offset; + ++column; + if (offset - begin > 1) { + extra_bytes += offset - begin - 1; + utf8_adjustments_.push_back({line_start, column, extra_bytes}); + } + } + } + void CollectRegions(const toml::node& node) + { + if (node.is_value() || node.is_array() || PhysicalInline(node)) { + const auto begin = Offset(node.source().begin), end = Offset(node.source().end); + if (end > begin) + value_ends[begin] = std::max(value_ends[begin], end); + } + if (auto table = node.as_table()) + for (const auto& [key, child] : *table) { + (void)key; + CollectRegions(child); + } + else if (auto array = node.as_array(); array && ArrayOfTables(node)) + for (const auto& child : *array) + CollectRegions(child); + } + static Path DecodeKey(std::string_view key) + { + const auto parsed = Parse(std::string(key) + " = 0"); + const toml::node* current = &parsed; + Path path; + while (current->is_table()) { + const auto& table = *current->as_table(); + if (table.size() != 1) + throw Failure("InternalError"); + const auto it = table.begin(); + path.emplace_back(it->first.str()); + current = &it->second; + } + if (!current->is_integer()) + throw Failure("InternalError"); + return path; + } + std::size_t KeyEnd(std::size_t begin, char terminator) const + { + char quote = 0; + bool escaped = false; + for (auto i = begin; i < text.size(); ++i) { + const char c = text[i]; + if (quote) { + if (quote == '"' && escaped) { + escaped = false; + continue; + } + if (quote == '"' && c == '\\') { + escaped = true; + continue; + } + if (c == quote) + quote = 0; + } else if (c == '"' || c == '\'') + quote = c; + else if (c == terminator) + return i; + else if (c == '\n') + break; + } + throw Failure("InternalError"); + } + std::size_t Horizontal(std::size_t position) const + { + while (position < text.size() && (text[position] == ' ' || text[position] == '\t' || text[position] == '\r')) + ++position; + return position; + } + std::size_t StatementEnd(std::size_t position) const + { + position = Horizontal(position); + if (position < text.size() && text[position] == '#') { + position = text.find('\n', position); + if (position == std::string::npos) + return text.size(); + } + if (position < text.size() && text[position] == '\n') + return position + 1; + if (position == text.size()) + return position; + throw Failure("InternalError"); + } + std::size_t ValueEnd(std::size_t begin) const + { + const auto found = value_ends.find(begin); + if (found == value_ends.end()) + throw Failure("InternalError"); + return found->second; + } + void ScanInline(const Path& path, std::size_t begin, std::size_t end) + { + Inline group{path, begin, end, {}}; + auto cursor = Horizontal(begin + 1); + while (cursor < end - 1) { + const auto key_end = KeyEnd(cursor, '='); + auto field_path = Append(path, DecodeKey(std::string_view(text).substr(cursor, key_end - cursor))); + const auto value_begin = Horizontal(key_end + 1), value_end = ValueEnd(value_begin); + group.fields.push_back({field_path, cursor, key_end, value_begin, value_end}); + if (text[value_begin] == '{') + ScanInline(field_path, value_begin, value_end); + cursor = Horizontal(value_end); + if (cursor < end - 1 && text[cursor] == ',') + cursor = Horizontal(cursor + 1); + else if (cursor != end - 1) + throw Failure("InternalError"); + } + inlines.push_back(std::move(group)); + } + void Scan() + { + auto cursor = Bom(); + Path context; + while (cursor < text.size()) { + const auto statement_begin = cursor; + cursor = Horizontal(cursor); + if (cursor == text.size()) + break; + if (text[cursor] == '\n') { + ++cursor; + continue; + } + if (text[cursor] == '#') { + cursor = StatementEnd(cursor); + continue; + } + if (text[cursor] == '[') { + const bool array = cursor + 1 < text.size() && text[cursor + 1] == '['; + const auto key_begin = cursor + (array ? 2 : 1), key_end = KeyEnd(key_begin, ']'); + context = DecodeKey(std::string_view(text).substr(key_begin, key_end - key_begin)); + const auto end = StatementEnd(key_end + (array ? 2 : 1)); + headers.push_back({context, statement_begin, end, key_begin, key_end, array}); + cursor = end; + } else { + const auto key_begin = cursor, key_end = KeyEnd(cursor, '='); + const auto path = Append(context, DecodeKey(std::string_view(text).substr(cursor, key_end - cursor))); + const auto value_begin = Horizontal(key_end + 1), value_end = ValueEnd(value_begin), + end = StatementEnd(value_end); + assignments.push_back({path, context, statement_begin, end, key_begin, key_end, value_begin, value_end}); + if (text[value_begin] == '{') + ScanInline(path, value_begin, value_end); + cursor = end; + } + } + } + }; + + inline toml::table& Parent(toml::table& root, const Path& path, bool create) + { + auto* table = &root; + for (std::size_t i = 0; i + 1 < path.size(); ++i) { + auto* node = table->get(path[i]); + if (!node && create) + node = &table->insert(path[i], toml::table{}).first->second; + if (!node || !node->is_table()) + throw Failure("UnsupportedTarget"); + table = node->as_table(); + } + return *table; + } + inline std::string Set(const Document& document, const Path& path, const toml::node& value) + { + const auto* current = document.Find(path); + if (current && document.ArrayOfTables(*current)) + throw Failure("UnsupportedTarget"); + auto desired = document.tree; + Parent(desired, path, true).insert_or_assign(path.back(), value); + if (desired == document.tree) + return document.text; + const auto encoded = Render(value); + if (current && (!current->is_table() || document.PhysicalInline(*current))) { + if (auto candidate = document.Apply( + {{document.Offset(current->source().begin), document.Offset(current->source().end), encoded}}, desired)) + return *candidate; + throw Failure("UnsupportedTarget"); + } + if (current) + throw Failure("UnsupportedTarget"); + const auto newline = document.Newline(); + // A physical inline ancestor permits dotted-key insertion, including implicit + // dotted descendants whose own source region is only a key token. + for (auto group = document.inlines.rbegin(); group != document.inlines.rend(); ++group) { + if (!Prefix(group->path, path) || group->path.size() >= path.size()) + continue; + const auto assignment = CanonicalPath(Relative(path, group->path.size())) + " = " + encoded; + const auto insertion = (group->fields.empty() ? " " : ", ") + assignment + " "; + if (auto candidate = document.Apply({{group->end - 1, group->end - 1, insertion}}, desired)) + return *candidate; + } + // Try relative assignments in the deepest existing declared ancestor first. + std::vector ancestors; + for (const auto& header : document.headers) + if (!header.array && Prefix(header.path, path) && header.path.size() < path.size()) + ancestors.push_back(&header); + std::sort(ancestors.begin(), ancestors.end(), + [](const Header* a, const Header* b) { return a->path.size() > b->path.size(); }); + for (const auto* header : ancestors) { + const auto assignment = CanonicalPath(Relative(path, header->path.size())) + " = " + encoded + newline; + const bool terminated = header->end && document.text[header->end - 1] == '\n'; + if (auto candidate = + document.Apply({{header->end, header->end, (terminated ? "" : newline) + assignment}}, desired)) + return *candidate; + } + auto append_section = [&]() -> std::optional { + if (path.size() < 2) + return {}; + const Path parent(path.begin(), path.end() - 1); + auto suffix = "[" + CanonicalPath(parent) + "]" + newline + EncodeKey(path.back()) + " = " + encoded + newline; + if (document.text.size() > document.Bom() && document.text.back() != '\n') + suffix = newline + suffix; + return document.Apply({{document.text.size(), document.text.size(), suffix}}, desired); + }; + if (path.size() > 1 && !document.Find(Path(path.begin(), path.end() - 1))) + if (auto candidate = append_section()) + return *candidate; + // Keep leading comments at the beginning; a root assignment belongs before + // the first table header, or after existing root statements and comments. + const auto root_position = document.headers.empty() ? document.text.size() : document.headers.front().begin; + auto rooted = CanonicalPath(path) + " = " + encoded + newline; + if (root_position == document.text.size() && document.text.size() > document.Bom() && document.text.back() != '\n') + rooted = newline + rooted; + if (auto candidate = document.Apply({{root_position, root_position, rooted}}, desired)) + return *candidate; + if (auto candidate = append_section()) + return *candidate; + throw Failure("UnsupportedTarget"); + } +} // namespace detail + +// This feature needs only scalar assignment. Deletion, table renaming and a +// general document API do not belong to the runtime settings writer. +inline std::string SetValue(std::string_view text, const Path& path, std::string_view value) +{ + CheckPath(path); + const detail::Document document(text); + const auto parsed = ValueDocument(value); + return detail::Set(document, path, *parsed.get("__stfc_value__")); +} +} // namespace config_edit::source diff --git a/tests/boolean_settings_test.cc b/tests/boolean_settings_test.cc new file mode 100644 index 000000000..d3c533409 --- /dev/null +++ b/tests/boolean_settings_test.cc @@ -0,0 +1,194 @@ +#include "settings/boolean_settings.h" +#include +#include + +using namespace mod_settings; +#define CHECK(x) \ + do { \ + if (!(x)) { \ + std::cerr << "FAILED line " << __LINE__ << ": " << #x << '\n'; \ + std::exit(1); \ + } \ + } while (false) + +struct Fake { + bool value = false, available = true, reject = false, fail_readback = false, throw_write = false; + unsigned reads = 0, writes = 0; + std::uint64_t generation = 1; + std::function on_read, on_write; + Definition definition(std::string id = "test.confirm") + { + return {std::move(id), "[MOD] Confirm test", + [this] { + ++reads; + if (on_read) + on_read(); + return available ? ReadResult::Known(value, generation) : ReadResult{}; + }, + [this](bool desired, std::uint64_t expected) { + ++writes; + CHECK(expected == generation); + if (on_write) + on_write(); + if (throw_write) + throw std::runtime_error("write"); + if (reject) + return ApplyResult::Rejected; + value = desired; + if (fail_readback) + available = false; + return ApplyResult::Applied; + }}; + } +}; + +int main() +{ + { + Fake f; + BooleanRegistry registry; + CHECK(registry.Register(f.definition()) == Registration::Added); + CHECK(registry.Register(f.definition()) == Registration::Duplicate); + CHECK(registry.Register({}) == Registration::Invalid); + CHECK(f.reads == 0 && f.writes == 0); // Construction schedules no work. + auto* s = registry.Find("test.confirm"); + CHECK(s); + CHECK(registry.Register(f.definition("late")) == Registration::Frozen); + CHECK(!registry.Find("missing")); + auto initial = s->Observe(); + CHECK(initial.state.known() && !*initial.state.value); + CHECK(s->SetFromUser(false, initial).outcome == Outcome::Unchanged); + CHECK(f.writes == 0); + auto result = s->SetFromUser(true, initial); + CHECK(result.outcome == Outcome::AppliedVerified && result.snapshot.state.value == true && f.writes == 1); + CHECK(s->SetFromUser(false, initial).outcome == Outcome::Conflict); + CHECK(f.writes == 1); + CHECK(s->SetFromUser(false, result.snapshot).outcome == Outcome::AppliedVerified); + CHECK(f.writes == 2); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + { + BooleanSetting::RenderScope outer(s); + BooleanSetting::RenderScope inner(s); + s.Observe(); + CHECK(s.SetFromUser(true, before).outcome == Outcome::Suppressed); + } + CHECK(f.writes == 0); + CHECK(s.SetFromUser(true, before).outcome == Outcome::AppliedVerified); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.on_write = [&] { CHECK(s.SetFromUser(true, before).outcome == Outcome::Busy); }; + CHECK(s.SetFromUser(true, before).outcome == Outcome::AppliedVerified); + CHECK(f.writes == 1); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.on_read = [&] { CHECK(s.SetFromUser(true, before).outcome == Outcome::Busy); }; + CHECK(s.SetFromUser(true, before).outcome == Outcome::AppliedVerified); + CHECK(f.writes == 1); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.reject = true; + auto result = s.SetFromUser(true, before); + CHECK(result.outcome == Outcome::Rejected && result.snapshot.state.value == false); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.fail_readback = true; + auto result = s.SetFromUser(true, before); + CHECK(result.outcome == Outcome::Unverified && !result.snapshot.state.value && f.value); + CHECK(f.writes == 1); // Never attempt an unverified reverse write. + f.available = true; + CHECK(s.Observe().state.value == true); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.throw_write = true; + CHECK(s.SetFromUser(true, before).outcome == Outcome::Unverified); + f.throw_write = false; + CHECK(s.SetFromUser(true, s.Observe()).outcome == Outcome::AppliedVerified); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.available = false; + CHECK(!s.Observe().state.value); + CHECK(s.SetFromUser(true, before).outcome == Outcome::Rejected); + CHECK(f.writes == 0); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + ++f.generation; + CHECK(s.SetFromUser(true, before).outcome == Outcome::Conflict); + CHECK(f.writes == 0); + before = s.Observe(); + s.InvalidateSession(); + CHECK(s.SetFromUser(true, before).outcome == Outcome::Conflict); + CHECK(f.writes == 0); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.on_write = [&] { s.InvalidateSession(); }; + CHECK(s.SetFromUser(true, before).outcome == Outcome::Unverified); + } + { + Fake f; + BooleanSetting s(f.definition()); + f.on_read = [&] { s.InvalidateSession(); }; + CHECK(!s.Observe().state.known()); + } + { + Fake f; + BooleanSetting s(f.definition()), other(f.definition("other")); + CHECK(s.SetFromUser(true, other.Observe()).outcome == Outcome::Conflict); + CHECK(f.writes == 0); + } + { + Fake f; + BooleanSetting s(f.definition()); + f.on_read = [&] { CHECK(!s.Observe().state.known()); }; + CHECK(s.Observe().state.known()); + } + { + Definition malformed{"invalid", "invalid", [] { return ReadResult{Availability::Known, true, 0}; }, + [](bool, std::uint64_t) { return ApplyResult::Applied; }}; + BooleanSetting s(std::move(malformed)); + CHECK(!s.Observe().state.value); + } + { + Fake f; + BooleanSetting s(f.definition()); + bool rejected = false; + std::thread other([&] { + try { + s.Observe(); + } catch (const std::logic_error&) { + rejected = true; + } + }); + other.join(); + CHECK(rejected && f.reads == 0); + } + std::cout + << "PASS boolean settings: registration, readback, failure, refresh, reentry, generation and thread contracts\n"; +} diff --git a/tests/boolean_view_test.cc b/tests/boolean_view_test.cc new file mode 100644 index 000000000..a20df4f9d --- /dev/null +++ b/tests/boolean_view_test.cc @@ -0,0 +1,84 @@ +#include "settings/boolean_view.h" +#include +#include + +using namespace mod_settings; +namespace +{ +BooleanView* observedView = nullptr; +BooleanSetting* observedSetting = nullptr; +int notifications = 0; +void Refresh() +{ + ++notifications; + observedView->Bind(); + assert(observedSetting->SetFromUser(false, observedSetting->Observe()).outcome == Outcome::Busy); +} +void OtherObserver() {} +} // namespace +int main() +{ + bool available = true, value = true; + int writes = 0; + ApplyResult apply = ApplyResult::Applied; + BooleanSetting setting({"test.confirmation", "Confirm test", + [&] { return available ? ReadResult::Known(value, 1) : ReadResult{}; }, + [&](bool desired, std::uint64_t) { + ++writes; + if (apply != ApplyResult::Rejected) + value = desired; + return apply; + }}); + BooleanView first(setting), second(setting); + assert(!first.value() && first.Request(false).outcome == Outcome::Rejected); + first.Bind(); + second.Bind(); + { + BooleanSetting::RenderScope render(setting); + assert(first.Request(false).outcome == Outcome::Suppressed); + assert(first.value() == true && !first.failed() && writes == 0); + } + assert(first.Request(false).outcome == Outcome::AppliedVerified); + assert(first.value() == false && writes == 1); + assert(second.Request(false).outcome == Outcome::Conflict); + assert(second.value() == false && second.failed() && writes == 1); + second.Bind(); + assert(!second.failed()); + apply = ApplyResult::Rejected; + assert(first.Request(true).outcome == Outcome::Rejected); + assert(first.value() == false && first.failed()); + apply = ApplyResult::Unverified; + assert(first.Request(true).outcome == Outcome::Unverified); + assert(!first.value() && !first.editable() && first.failed()); + auto count = writes; + assert(first.Request(false).outcome == Outcome::Rejected && writes == count); + first.Bind(); + assert(first.value() == true); + setting.InvalidateSession(); + first.Invalidate(); + second.Invalidate(); + assert(!first.value() && first.Request(false).outcome == Outcome::Rejected); + available = false; + first.Bind(); + assert(!first.value()); + available = true; + first.Bind(); + assert(first.value() == true); + first.Unbind(); + assert(!first.value() && first.Request(false).outcome == Outcome::Rejected); + assert(writes == count); + apply = ApplyResult::Applied; + value = false; + first.Bind(); + observedView = &first; + observedSetting = &setting; + assert(setting.SetChangeObserver(Refresh)); + assert(setting.SetChangeObserver(Refresh)); + assert(!setting.SetChangeObserver(OtherObserver)); + // Recovery shortcut uses the shared setting directly, outside BooleanView. + assert(setting.SetFromUser(true, setting.Observe()).outcome == Outcome::AppliedVerified); + assert(first.value() == true && notifications == 1 && writes == count + 1); + assert(setting.SetFromUser(true, setting.Observe()).outcome == Outcome::Unchanged); + assert(first.value() == true && notifications == 2 && writes == count + 1); + std::cout << "PASS boolean view: render, stale views, unavailable, uncertain writes and teardown\n"; +} diff --git a/tests/choice_setting_test.cc b/tests/choice_setting_test.cc new file mode 100644 index 000000000..69fbe336b --- /dev/null +++ b/tests/choice_setting_test.cc @@ -0,0 +1,93 @@ +#include "settings/native_boolean_callback.h" +#include "settings/native_view_state.h" +#include "settings/page_catalog.h" +#include +#include + +using namespace mod_settings; +int stored = 0; +int GetInt(Il2CppObject*, const MethodInfo*) +{ return stored; } +void SetInt(Il2CppObject*, int value, const MethodInfo*) +{ stored = value; } +int main() +{ + int selected = 1, writes = 0; + ChoiceSetting setting({"mode", "Mode", [&] { return ValueReadResult::Known(selected, 1); }, + [&](int value, std::uint64_t) { + ++writes; + selected = value; + return ApplyResult::Applied; + }}, + {"Normal", "Warp", "Jump"}); + NativeViewState normal(setting, 0), jump(setting, 2); + PageCatalog catalog("root", "Settings"); + assert(catalog.AddPage("navigation", "Navigation", "root") == Registration::Added); + assert(catalog.AddChoice("navigation", setting) == Registration::Added); + assert(catalog.AddChoice("navigation", setting) == Registration::Duplicate); + const auto plan = catalog.Build(); + assert(plan.size() == 2 && std::get(plan[1].items[0]) == &setting); + assert(catalog.AddChoice("navigation", setting) == Registration::Frozen); + normal.Bind(); + jump.Bind(); + assert(normal.value() == false && jump.value() == false); + assert(normal.Request(true) == Outcome::AppliedVerified && selected == 0 && writes == 1); + // Jump was false before and after. Its whole-value snapshot must still conflict. + assert(jump.Request(true) == Outcome::Conflict && writes == 1); + jump.Bind(); + { + NativeViewState::RenderScope scope(jump); + assert(jump.Request(true) == Outcome::Suppressed && writes == 1); + } + assert(jump.Request(true) == Outcome::AppliedVerified && selected == 2 && writes == 2); + assert(jump.Request(false) == Outcome::Unchanged && selected == 2 && writes == 2); + assert(jump.Request(true) == Outcome::Unchanged && writes == 2); + jump.Unbind(); + assert(jump.Request(true) == Outcome::Rejected && writes == 2); + auto snapshot = setting.state().Observe(); + assert(setting.state().SetFromUser(3, snapshot).outcome == Outcome::Rejected && writes == 2); + selected = -1; + normal.Bind(); + assert(!normal.value() && normal.Request(true) == Outcome::Rejected && writes == 2); + + // Audio has ten built-ins (including Off) plus a retained custom-file slot. + int audio_value = 10; + ChoiceSetting audio( + {"audio", "Sound", [&] { return ValueReadResult::Known(audio_value, 1); }, + [&](int value, std::uint64_t) { + audio_value = value; + return ApplyResult::Applied; + }}, + {"Off", "Default", "Info", "Success", "Warning", "Alarm", "Arrival", "Soft", "Ping", "Repair", "Custom"}); + PageCatalog audio_pages("audio_root", "Audio"); + assert(audio_pages.AddPage("alert", "Alert", "audio_root") == Registration::Added); + assert(audio_pages.AddChoice("alert", audio) == Registration::Added); + assert(audio_pages.Build().at(1).ControlRows() == 11); + NativeViewState custom(audio, 10), off(audio, 0); + custom.Bind(); + off.Bind(); + assert(custom.value() == true && off.value() == false); + assert(off.Request(true) == Outcome::AppliedVerified && audio_value == 0); + custom.Bind(); + assert(custom.Request(true) == Outcome::AppliedVerified && audio_value == 10); + assert(audio.state().SetFromUser(11, audio.state().Observe()).outcome == Outcome::Rejected); + + Il2CppType integer{}, nothing{}; + integer.type = IL2CPP_TYPE_I4; + nothing.type = IL2CPP_TYPE_VOID; + const Il2CppType* parameters[]{&integer}; + MethodInfo getter{}, setter{}; + getter.return_type = &integer; + setter.return_type = ¬hing; + setter.parameters = parameters; + setter.parameters_count = 1; + NativeCallback get; + NativeCallback set; + assert(get.Initialize(&getter, GetInt) && set.Initialize(&setter, SetInt)); + int input = 2, output = -1; + void* args[]{&input}; + set.method()->invoker_method(nullptr, set.method(), nullptr, args, nullptr); + get.method()->invoker_method(nullptr, get.method(), nullptr, nullptr, &output); + assert(output == 2); + std::cout << "PASS choice setting: whole-value conflicts, readback, guards, range and Int32 callbacks\n"; +} diff --git a/tests/config_save_failure_test.cc b/tests/config_save_failure_test.cc new file mode 100644 index 000000000..8bc083c3c --- /dev/null +++ b/tests/config_save_failure_test.cc @@ -0,0 +1,60 @@ +#include +#include + +static bool failClose = false; + +static std::size_t ShortWrite(const void* data, std::size_t size, std::size_t count, std::FILE* file) +{ + if (failClose) { + return std::fwrite(data, size, count, file); + } + const auto written = std::fwrite(data, size, count / 2, file); + errno = ENOSPC; + return written; +} + +static int FailedClose(std::FILE* file) +{ + std::fclose(file); + errno = ENOSPC; + return EOF; +} + +// Exercise the production cleanup path without adding runtime injection controls. +#define CONFIG_SAVE_WRITE ShortWrite +#define CONFIG_SAVE_CLOSE FailedClose +#include "../mods/src/config_save.cc" + +#include +#include +#include + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + const std::string original = "# keep this exactly\nenabled = false\n"; + { + std::ofstream out(path, std::ios::binary); + out << original; + } + for (bool closeFailure : {false, true}) { + failClose = closeFailure; + bool failed = false; + try { + SaveConfigDocument(toml::table{{"enabled", true}}, path); + } catch (const std::system_error&) { + failed = true; + } + assert(failed); + std::ifstream input(path, std::ios::binary); + const std::string actual(std::istreambuf_iterator{input}, {}); + assert(actual == original); + for (const auto& entry : std::filesystem::directory_iterator(root)) { + assert(entry.path() == path); + } + } + std::cout << "Short-write and failed-close fixtures passed\n"; +} diff --git a/tests/config_save_test.cc b/tests/config_save_test.cc new file mode 100644 index 000000000..c34c7c409 --- /dev/null +++ b/tests/config_save_test.cc @@ -0,0 +1,74 @@ +#include "config_save.h" + +#include +#include +#include + +#if _WIN32 +#include +#endif + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + const std::string value = "quotes: \"'\\\n[unexpected]\nenabled = true\nUnicode: \xc3\xa9"; + toml::table config{{"value", value}, {"enabled", false}}; + SaveConfigDocument(config, path, "# generated\n"); + auto parsed = toml::parse_file(path.string()); + assert(parsed["value"].value() == value); + assert(parsed.size() == 2); + config.insert_or_assign("enabled", true); + SaveConfigDocument(config, path); + assert(toml::parse_file(path.string())["enabled"].value() == true); + + bool failed = false; + try { + SaveConfigDocument(config, path, "invalid = [\n"); + } catch (const std::exception&) { + failed = true; + } + assert(failed); + assert(toml::parse_file(path.string())["value"].value() == value); + + const auto directory = root / "occupied"; + std::filesystem::create_directory(directory); + failed = false; + try { + SaveConfigDocument(config, directory); + } catch (const std::exception&) { + failed = true; + } + assert(failed && std::filesystem::is_directory(directory)); +#if _WIN32 + // A real sharing violation must leave the previous readable document intact. + auto handle = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr); + assert(handle != INVALID_HANDLE_VALUE); + failed = false; + config.insert_or_assign("enabled", false); + try { + SaveConfigDocument(config, path); + } catch (const std::exception&) { + failed = true; + } + CloseHandle(handle); + assert(failed); + assert(toml::parse_file(path.string())["enabled"].value() == true); +#else + const auto mode = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write; + std::filesystem::permissions(path, mode); + const auto link = root / "linked.toml"; + std::filesystem::create_symlink(path, link); + config.insert_or_assign("enabled", false); + SaveConfigDocument(config, link); + assert(std::filesystem::is_symlink(link)); + assert(toml::parse_file(path.string())["enabled"].value() == false); + assert(std::filesystem::status(path).permissions() == mode); +#endif + for (const auto& entry : std::filesystem::directory_iterator(root)) { + assert(entry.path().filename().string().find(".tmp-") == std::string::npos); + } + std::cout << "Config save fixtures passed\n"; +} diff --git a/tests/native_boolean_callback_test.cc b/tests/native_boolean_callback_test.cc new file mode 100644 index 000000000..4ab1a0e4a --- /dev/null +++ b/tests/native_boolean_callback_test.cc @@ -0,0 +1,93 @@ +#include "settings/native_boolean_callback.h" +#include +#include +#include + +namespace +{ +int donorCalls = 0, calls = 0; +bool Donor(Il2CppObject*, const MethodInfo*) +{ + ++donorCalls; + return false; +} +bool Getter(Il2CppObject*, const MethodInfo*) +{ + ++calls; + return true; +} +void Setter(Il2CppObject*, bool value, const MethodInfo*) +{ calls += value ? 10 : 20; } +Il2CppObject* invokedTarget = nullptr; +void Command(Il2CppObject* target, const MethodInfo*) +{ invokedTarget = target; } +Il2CppString text{}; +Il2CppString* TextGetter(Il2CppObject*, const MethodInfo*) +{ return &text; } +} // namespace +int main() +{ + Il2CppType boolType{}, voidType{}; + boolType.type = IL2CPP_TYPE_BOOLEAN; + voidType.type = IL2CPP_TYPE_VOID; + MethodInfo schema{}; + schema.return_type = &boolType; + schema.methodPointer = reinterpret_cast(Donor); + schema.virtualMethodPointer = schema.methodPointer; + mod_settings::NativeCallback get; + assert(get.Initialize(&schema, Getter)); + assert(!get.Initialize(&schema, Getter)); + assert(schema.methodPointer == reinterpret_cast(Donor)); + auto* owned = get.method(); + auto direct = reinterpret_cast(owned->methodPointer); + assert(direct(nullptr, owned)); + auto virt = reinterpret_cast(owned->virtualMethodPointer); + assert(virt(nullptr, owned)); + bool value = false; + owned->invoker_method(schema.methodPointer, owned, nullptr, nullptr, &value); + assert(value && calls == 3 && donorCalls == 0); + schema.return_type = &voidType; + const Il2CppType* parameters[] = {&boolType}; + schema.parameters_count = 1; + schema.parameters = parameters; + mod_settings::NativeCallback set; + assert(set.Initialize(&schema, Setter)); + void* args[] = {&value}; + owned = set.method(); + owned->invoker_method(nullptr, owned, nullptr, args, nullptr); + assert(calls == 13 && donorCalls == 0); + mod_settings::NativeCallback invalid; + assert(!invalid.Initialize(&schema, Getter)); + schema.parameters_count = 0; + schema.parameters = nullptr; + schema.flags = METHOD_ATTRIBUTE_SPECIAL_NAME | METHOD_ATTRIBUTE_RT_SPECIAL_NAME; + mod_settings::NativeCallback command; + assert(command.Initialize(&schema, Command)); + Il2CppObject token{}; + command.method()->invoker_method(nullptr, command.method(), &token, nullptr, nullptr); + assert(invokedTarget == &token); + invokedTarget = nullptr; + reinterpret_cast(command.method()->methodPointer)(&token, command.method()); + assert(invokedTarget == &token && donorCalls == 0); + schema.return_type = &boolType; + schema.flags = METHOD_ATTRIBUTE_VIRTUAL; + assert(!invalid.Initialize(&schema, Getter)); + schema.flags = 0; + schema.is_inflated = true; + assert(!invalid.Initialize(&schema, Getter)); + Il2CppType stringType{}; + stringType.type = IL2CPP_TYPE_STRING; + MethodInfo stringSchema{}; + stringSchema.return_type = &stringType; + mod_settings::NativeCallback stringGet; + assert(stringGet.Initialize(&stringSchema, TextGetter)); + auto* stringMethod = stringGet.method(); + Il2CppString* result = nullptr; + stringMethod->invoker_method(nullptr, stringMethod, nullptr, nullptr, &result); + assert(result == &text); + assert(reinterpret_cast(stringMethod->methodPointer)(nullptr, stringMethod) == &text); + assert(reinterpret_cast(stringMethod->virtualMethodPointer)(nullptr, stringMethod) == &text); + mod_settings::NativeCallback wrongString; + assert(!wrongString.Initialize(&schema, TextGetter)); + std::cout << "PASS owned callback: direct, virtual and runtime invoker paths; donor remains untouched\n"; +} diff --git a/tests/page_catalog_test.cc b/tests/page_catalog_test.cc new file mode 100644 index 000000000..b9ac57380 --- /dev/null +++ b/tests/page_catalog_test.cc @@ -0,0 +1,215 @@ +#include "settings/boolean_view.h" +#include "settings/page_catalog.h" +#include +#include + +using namespace mod_settings; +int main() +{ + unsigned reads = 0, writes = 0; + bool value = false; + BooleanSetting setting({"mod.example.enabled", "Example", + [&] { + ++reads; + return ReadResult::Known(value, 1); + }, + [&](bool desired, std::uint64_t) { + ++writes; + value = desired; + return ApplyResult::Applied; + }}); + PageCatalog catalog("mod.settings", "Mod Settings"); + assert(catalog.AddPage("group.a", "Group A", "mod.settings") == Registration::Added); + assert(catalog.AddPage("group.b", "Group B", "mod.settings") == Registration::Added); + assert(catalog.AddPage("nested", "Nested", "group.a") == Registration::Added); + assert(catalog.AddPage("empty", "Empty", "mod.settings") == Registration::Added); + assert(catalog.AddPage("orphan", "Orphan", "missing") == Registration::Invalid); + assert(catalog.AddPage("group.a", "Duplicate", "mod.settings") == Registration::Duplicate); + assert(catalog.AddBoolean("nested", setting) == Registration::Added); + assert(catalog.AddBoolean("nested", setting) == Registration::Duplicate); + assert(catalog.AddBoolean("group.b", setting) == Registration::Added); + BooleanSetting collision({"mod.example.enabled", "Other", [] { return ReadResult::Known(true, 1); }, + [](bool, std::uint64_t) { return ApplyResult::Applied; }}); + assert(catalog.AddBoolean("group.a", collision) == Registration::Invalid); + auto first = catalog.Build(); + assert(reads == 0 && writes == 0); + assert(first.size() == 4 && first[0].id == "mod.settings"); + assert(first[3].parent == "group.a"); + first.clear(); // Destroy the old presentation plan before another visit. + auto second = catalog.Build(); + assert(second.size() == 4 && std::get(second[3].items[0]) == &setting); + assert(catalog.AddPage("late", "Late", "mod.settings") == Registration::Frozen); + assert(catalog.AddBoolean("group.a", setting) == Registration::Frozen); + + BooleanView view(*std::get(second[3].items[0])); + view.Bind(); + assert(view.Request(true).outcome == Outcome::AppliedVerified); + assert(value && writes == 1); + view.Unbind(); + auto third = catalog.Build(); + BooleanView mirror(*std::get(third[2].items[0])); + mirror.Bind(); + assert(mirror.value() == true && writes == 1); + assert(view.Request(false).outcome == Outcome::Rejected); // Old page cannot write. + + PageCatalog empty("root", "Empty"); + assert(empty.AddPage("unused", "Unused", "root") == Registration::Added); + assert(empty.AddHeading("unused", "lonely", "Heading without controls") == Registration::Added); + assert(empty.Build().empty()); + PageCatalog malformed("malformed", "Malformed controls"); + BooleanSetting missingId({"", "Label", {}, {}}), missingLabel({"id", "", {}, {}}); + assert(malformed.AddBoolean("malformed", missingId) == Registration::Invalid); + assert(malformed.AddBoolean("malformed", missingLabel) == Registration::Invalid); + for (bool emptyId : {true, false}) { + SliderSetting invalid({emptyId ? "" : "slider", emptyId ? "Label" : "", + [] { return ValueReadResult::Known(0.5f, 1); }, + [](float, std::uint64_t) { return ApplyResult::Applied; }}, + 0, 1, 0.01f, [] { return true; }); + assert(malformed.AddSlider("malformed", invalid) == Registration::Invalid); + } + assert(malformed.Build().empty()); + ChoiceSetting player({"player", "Player detail", [] { return ValueReadResult::Known(0, 1); }, + [](int, std::uint64_t) { return ApplyResult::Applied; }}, + {"Native", "Expanded", "Compact", "Threshold"}); + ChoiceSetting other({"other", "Other detail", [] { return ValueReadResult::Known(0, 1); }, + [](int, std::uint64_t) { return ApplyResult::Applied; }}, + {"Native", "Expanded", "Compact", "Threshold"}); + SliderSetting playerSlider({"player.zoom", "Threshold", [] { return ValueReadResult::Known(0.5f, 1); }, + [](float, std::uint64_t) { return ApplyResult::Applied; }}, + 0, 1, 0.01f, [] { return true; }); + SliderSetting otherSlider({"other.zoom", "Threshold", [] { return ValueReadResult::Known(0.5f, 1); }, + [](float, std::uint64_t) { return ApplyResult::Applied; }}, + 0, 1, 0.01f, [] { return true; }); + // Command registration/building is presentation-only: no invocation or read. + int commandCalls = 0; + std::size_t commandCount = 2; + ActionSetting command{"record", "Record", + [&](std::size_t) { + ++commandCalls; + return ActionSetting::Presentation{}; + }, + [&](std::size_t) { ++commandCalls; }, + [&] { + ++commandCalls; + return commandCount; + }}; + PageCatalog commands("commands", "Commands"); + assert(commands.AddAction("commands", command) == Registration::Added); + assert(commands.AddAction("commands", command) == Registration::Duplicate); + const auto commandPlan = commands.Build(); + assert(commandPlan.size() == 1 && commandPlan.front().ControlRows() == 1 && commandCalls == 0); + assert(commands.AddAction("commands", command) == Registration::Frozen); + // Registration counts definitions without evaluating dynamic rows. Repeated + // native identities survive shrink/regrowth and reject noncanonical suffixes. + assert(command.item_index(command.item_id(42)) == 42); + for (const auto* id : {"record", "record.row.", "record.row.-1", "record.row.01", "record.row.1x", "other.row.1", + "record.row.999999999999999999999999999999999999"}) + assert(!command.item_index(id)); + command.read = [&](std::size_t index) { + ++commandCalls; + return ActionSetting::Presentation{std::to_string(index), "Change", "", true, true}; + }; + assert(command.Read(1).actionable()); + commandCount = 1; + const auto beforeRead = commandCalls; + assert(!command.Read(1).visible && commandCalls == beforeRead + 1); // Count only; no stale reader call. + assert(!command.Read(1).actionable()); + commandCount = 2; + assert(command.Read(1).label == "1"); + auto presentation = command.Read(0); + presentation.visible = false; + assert(!presentation.actionable()); + presentation.visible = true; + presentation.button.clear(); + assert(!presentation.actionable()); // Information rows do not expose a blank button. + presentation.button = "Change"; + presentation.enabled = false; + assert(!presentation.actionable()); + assert(commandPlan[0].PositionFor(command.id()) == 0); + assert(commandPlan[0].PositionFor("unknown") == commandPlan[0].items.size()); + PageCatalog combined("labels", "Fleet Labels"); + assert(combined.AddHeading("labels", "player.heading", "Player", true) == Registration::Added); + assert(combined.AddChoice("labels", player) == Registration::Added); + assert(combined.AddSlider("labels", playerSlider) == Registration::Added); + assert(combined.AddHeading("labels", "other.heading", "Non-player", true) == Registration::Added); + assert(combined.AddChoice("labels", other) == Registration::Added); + assert(combined.AddSlider("labels", otherSlider) == Registration::Added); + const auto ordered = combined.Build(); + assert(ordered.size() == 1 && ordered[0].ControlRows() == 10 && ordered[0].items.size() == 6); + assert(std::get(ordered[0].items[0]).label == "Player"); + assert(std::get(ordered[0].items[1]) == &player); + assert(std::get(ordered[0].items[2]) == &playerSlider); + assert(std::get(ordered[0].items[3]).label == "Non-player"); + assert(std::get(ordered[0].items[4]) == &other); + assert(std::get(ordered[0].items[5]) == &otherSlider); + assert(ordered[0].SectionFor("player")->id == "player.heading"); + assert(ordered[0].SectionFor("player.zoom")->id == "player.heading"); + assert(ordered[0].SectionFor("other")->id == "other.heading"); + assert(ordered[0].SectionFor("other.zoom")->id == "other.heading"); + assert(!ordered[0].SectionFor("player.heading")); // Headers always remain visible. + assert(!ordered[0].SectionFor("unknown")); + PageCatalog boundaries("boundaries", "Section boundaries"); + assert(boundaries.AddHeading("boundaries", "boundaries", "Collision", true) == Registration::Invalid); + assert(boundaries.AddChoice("boundaries", player) == Registration::Added); + assert(boundaries.AddHeading("boundaries", "collapsible", "Collapsible", true) == Registration::Added); + assert(boundaries.AddPage("collapsible", "Collision", "boundaries") == Registration::Invalid); + assert(boundaries.AddSlider("boundaries", playerSlider) == Registration::Added); + assert(boundaries.AddHeading("boundaries", "plain", "Plain") == Registration::Added); + assert(boundaries.AddSlider("boundaries", otherSlider) == Registration::Added); + const auto bounded = boundaries.Build(); + assert(!bounded[0].SectionFor("player")); // Controls before a heading are unaffected. + assert(bounded[0].SectionFor("player.zoom")->id == "collapsible"); + assert(!bounded[0].SectionFor("other.zoom")); // A plain heading ends a collapsible section. + bool masterOn = false, masterAvailable = true; + int visibilityReads = 0; + BooleanSetting master({"master", + "Master", + [&] { + ++visibilityReads; + return masterAvailable ? ReadResult::Known(masterOn, 1) : ReadResult{}; + }, + {}}); + BooleanSetting dependent({"dependent", "Dependent", [] { return ReadResult::Known(true, 1); }, {}}); + PageCatalog conditional("conditional", "Conditional sections"); + assert(conditional.AddBoolean("conditional", master) == Registration::Added); + assert(conditional.AddBoolean("conditional", dependent, [&] { + const auto state = master.Observe().state; + return state.known() && *state.value; + }) == Registration::Added); + assert(conditional.AddHeading("conditional", "targets", "Targets", false, [&] { + const auto state = master.Observe().state; + return state.known() && *state.value; + }) == Registration::Added); + assert(conditional.AddBoolean("conditional", setting) == Registration::Added); + assert(conditional.AddHeading("conditional", "later", "Later section") == Registration::Added); + assert(conditional.AddSlider("conditional", playerSlider) == Registration::Added); + const auto conditionalPlan = conditional.Build(); + const auto& page = conditionalPlan.front(); + assert(visibilityReads == 0); // Building retains hidden controls without reading their dependency. + assert(page.HasConditionalSections() && !bounded[0].HasConditionalSections()); + const auto writesBeforeVisibility = writes; + for (bool enabled : {false, true, false, true}) { + masterOn = enabled; // A live change from either UI or shortcut uses the same reader. + assert(page.IsVisible("master")); + assert(page.IsVisible("dependent") == enabled); + assert(page.IsVisible("targets") == enabled); + assert(page.IsVisible(setting.id()) == enabled); + assert(page.IsVisible("later") && page.IsVisible(playerSlider.state().id())); + assert(value && writes == writesBeforeVisibility); // Hiding/revealing never clears a target preference. + } + masterAvailable = false; + assert(!page.IsVisible("dependent")); + assert(!page.IsVisible("targets") && !page.IsVisible(setting.id())); + assert(page.IsVisible("master") && page.IsVisible("unknown")); + bool rejected = false; + std::thread wrong_thread([&] { + try { + (void)catalog.Build(); + } catch (const std::logic_error&) { + rejected = true; + } + }); + wrong_thread.join(); + assert(rejected); + std::cout << "Settings catalog rebuild/identity/lifetime fixtures passed\n"; +} diff --git a/tests/page_sections_test.cc b/tests/page_sections_test.cc new file mode 100644 index 000000000..c94f325aa --- /dev/null +++ b/tests/page_sections_test.cc @@ -0,0 +1,91 @@ +#include "settings/page_sections.h" +#include +#include + +using namespace mod_settings; + +// Run the same visit contract for the fleet and galaxy page shapes. +void CheckPage(bool galaxy) +{ + bool overlays = true; + int writes = 0; + auto rejectWrite = [&](auto, std::uint64_t) { + ++writes; + return ApplyResult::Rejected; + }; + BooleanSetting master({"master", "Multiple overlays", [&] { return ReadResult::Known(overlays, 1); }, + [&](bool, std::uint64_t) { + ++writes; + return ApplyResult::Rejected; + }}); + BooleanSetting overlay({"overlay", "Overlay", [] { return ReadResult::Known(true, 1); }, rejectWrite}); + ChoiceSetting first({"first", "First", [] { return ValueReadResult::Known(1, 1); }, rejectWrite}, + {"Native", "Always", "Threshold"}); + ChoiceSetting second({"second", "Second", [] { return ValueReadResult::Known(2, 1); }, rejectWrite}, + {"Native", "Always", "Threshold"}); + PageCatalog catalog("labels", galaxy ? "Galaxy Labels" : "Fleet Labels"); + if (galaxy) { + catalog.AddBoolean("labels", master); + catalog.AddHeading("labels", "overlays", "Overlays", false, [&] { return overlays; }); + catalog.AddBoolean("labels", overlay); + } + catalog.AddHeading("labels", "first.heading", galaxy ? "Major systems" : "Player", true); + catalog.AddChoice("labels", first); + catalog.AddHeading("labels", "second.heading", galaxy ? "Minor systems" : "Non-player", true); + catalog.AddChoice("labels", second); + const auto pages = catalog.Build(); + const auto& page = pages.front(); + const auto& firstHeading = *page.SectionFor("first"); + const auto& secondHeading = *page.SectionFor("second"); + PageSections sections; + for (int visit = 0; visit < 2; ++visit) { + sections.Begin(page); + assert(sections.Visible(page, "first.heading") && sections.Visible(page, "second.heading")); + assert(!sections.Visible(page, "first") && !sections.Visible(page, "second")); + if (galaxy) { + assert(sections.Visible(page, "master") && sections.Visible(page, "overlays")); + assert(page.SectionFor("overlay") == nullptr); + assert(sections.Visible(page, "overlay")); + overlays = false; + assert(!sections.Visible(page, "overlays") && !sections.Visible(page, "overlay")); + assert(sections.Visible(page, "master") && sections.Visible(page, "first.heading")); + overlays = true; + assert(sections.Visible(page, "overlay")); // Direct controls return without an expansion click. + } + sections.Toggle(firstHeading); + assert(sections.Visible(page, "first") && !sections.Visible(page, "second")); + const auto beforeFailedBind = sections.Snapshot(); + sections.Toggle(secondHeading); + sections.Restore(beforeFailedBind); + assert(sections.Visible(page, "first") && !sections.Visible(page, "second")); + sections.Toggle(secondHeading); + assert(sections.Visible(page, "first") && sections.Visible(page, "second")); + sections.Toggle(firstHeading); + assert(!sections.Visible(page, "first") && sections.Visible(page, "second")); + // Leave one section open; the next Begin must reset the whole visit. + } + sections.ExpandAll(); // Existing accessible fallback after a failed folded bind. + assert(sections.Visible(page, "first") && sections.Visible(page, "second")); + assert(writes == 0); +} + +int main() +{ + CheckPage(false); + CheckPage(true); + PageSections sections; + assert(!sections.Refreshing()); + { + PageSections::RefreshScope initialPopulation(sections); + assert(sections.Refreshing()); + try { + PageSections::RefreshScope nested(sections); + assert(sections.Refreshing()); + throw 1; + } catch (int) { + } + assert(sections.Refreshing()); // Nested exit must not expose the unfinished outer bind. + } + assert(!sections.Refreshing()); + std::cout << "Shared fleet/galaxy section visit and refresh fixtures passed\n"; +} diff --git a/tests/persisted_setting_test.cc b/tests/persisted_setting_test.cc new file mode 100644 index 000000000..15c29fab6 --- /dev/null +++ b/tests/persisted_setting_test.cc @@ -0,0 +1,73 @@ +#include "settings/persisted_setting.h" +#include +#include +#include + +namespace +{ +using Key = std::pair; +std::map registrations; +std::map values; +unsigned saves = 0; +} // namespace +namespace runtime_config +{ +bool RegisterSetting(const char* section, const char* key) noexcept +{ + ++registrations[{section, key}]; + return true; +} +void SaveSetting(const char* section, const char* key, config_edit::Value value, std::chrono::milliseconds) noexcept +{ + values.insert_or_assign(Key{section, key}, std::move(value)); + ++saves; +} +mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key) noexcept +{ + return values.contains(Key{section, key}) ? mod_settings::SaveStatus{mod_settings::SaveState::Saved, saves} + : mod_settings::SaveStatus{}; +} +} // namespace runtime_config + +int main() +{ + using namespace mod_settings; + bool current = true; + ValueSetting setting(PersistedDefinition( + {"feature", "Allow feature", [&] { return ValueReadResult::Known(current, 1); }, + [&](bool value, std::uint64_t) { + current = value; + return ApplyResult::Applied; + }}, + {"ui", "disable_feature", "Description"}, true, [](bool allow) -> config_edit::Value { return !allow; })); + assert(registrations[Key("ui", "disable_feature")] == 1); + assert(values.empty() && saves == 0); // Construction registers; it never writes. + setting.Observe(); + assert(values.empty()); + assert(setting.SetFromUser(false, setting.Observe()).outcome == Outcome::AppliedVerified); + assert(std::get(values.at(Key("ui", "disable_feature")))); + assert(setting.metadata().TomlKey() == "ui.disable_feature"); + assert(setting.save_status().state == SaveState::Saved); + assert(setting.Reset(setting.Observe()).outcome == Outcome::AppliedVerified); + assert(!std::get(values.at(Key("ui", "disable_feature")))); + + int number = 2; + ValueSetting numeric( + PersistedDefinition({"number", "Number", [&] { return ValueReadResult::Known(number, 1); }, + [&](int value, std::uint64_t) { + number = value; + return ApplyResult::Applied; + }}, + {"control", "number"}, 2, std::chrono::milliseconds(150))); + assert(numeric.SetFromUser(3, numeric.Observe()).outcome == Outcome::AppliedVerified); + assert(std::get(values.at(Key("control", "number"))) == 3); + assert(registrations.size() == 2); // No independent permitted-key whitelist. + bool invalid = false; + try { + PersistedDefinition({}, {}, 0); + } catch (const std::invalid_argument&) { + invalid = true; + } + assert(invalid); + std::cout << "Persisted definition registration/serialization fixtures passed\n"; +} diff --git a/tests/run-settings.ps1 b/tests/run-settings.ps1 new file mode 100644 index 000000000..edc901d8f --- /dev/null +++ b/tests/run-settings.ps1 @@ -0,0 +1,122 @@ +[CmdletBinding()] +param([string]$TomlInclude, [string]$JsonInclude) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$testRoot = Join-Path $repoRoot 'build/settings-test' +$fixtureRoot = Join-Path $testRoot ([guid]::NewGuid().ToString()) + +function Invoke-SettingsFixture { + param([string]$Name, [string[]]$Sources, [string[]]$TestArguments = @(), + [string[]]$IncludeDirectories = @()) + $executable = Join-Path $testRoot ($Name + '.exe') + $compilerArguments = @('--driver-mode=cl', '/std:c++latest', '/EHsc', '/MT', '/DNOMINMAX') + $compilerArguments += @($IncludeDirectories | ForEach-Object { "/I$_" }) + $compilerArguments += @('/Imods/src', '/Itests', '/Ithird_party/libil2cpp', "/I$TomlInclude", + "/Fe$executable", "/Fo$testRoot/", '-Wno-deprecated-literal-operator') + & clang++ @compilerArguments @Sources + if ($LASTEXITCODE -ne 0) { throw "Settings fixture compilation failed: $Name" } + & $executable @TestArguments + if ($LASTEXITCODE -ne 0) { throw "Settings fixture failed: $Name" } +} + +function Get-PermissionState([string]$Path) { + $acl = Get-Acl -LiteralPath $Path + [ordered]@{ + Owner = $acl.Owner + Group = $acl.Group + Protected = $acl.AreAccessRulesProtected + Rules = @($acl.Access | ForEach-Object { + [ordered]@{ + Identity = $_.IdentityReference.Value + Rights = $_.FileSystemRights.ToString() + Type = $_.AccessControlType.ToString() + Inherited = $_.IsInherited + Inheritance = $_.InheritanceFlags.ToString() + Propagation = $_.PropagationFlags.ToString() + } + }) + } | ConvertTo-Json -Depth 5 -Compress +} + +Push-Location $repoRoot +try { + if (-not $TomlInclude) { + $packageRoot = Join-Path $env:LOCALAPPDATA '.xmake/packages/t/toml++' + $header = Get-ChildItem -LiteralPath $packageRoot -Recurse -Filter toml.h | + Where-Object { $_.Directory.Name -eq 'toml++' } | Select-Object -First 1 + if (-not $header) { throw 'Build the mod first, or supply -TomlInclude.' } + $TomlInclude = $header.Directory.Parent.FullName + } + if (-not $JsonInclude) { + $packageRoot = Join-Path $env:LOCALAPPDATA '.xmake/packages/n/nlohmann_json' + $header = Get-ChildItem -LiteralPath $packageRoot -Recurse -Filter json.hpp | + Where-Object { $_.Directory.Name -eq 'nlohmann' } | Select-Object -First 1 + if (-not $header) { throw 'Build the mod first, or supply -JsonInclude.' } + $JsonInclude = $header.Directory.Parent.FullName + } + New-Item -ItemType Directory -Force $fixtureRoot | Out-Null + foreach ($fixture in @('boolean_settings', 'boolean_view', 'native_boolean_callback', 'page_catalog', + 'page_sections', 'settings_search', 'choice_setting', 'slider_setting', 'shortcut_editor', + 'settings_metadata', 'persisted_setting', 'settings_maintenance')) { + Invoke-SettingsFixture -Name $fixture -Sources @("tests/${fixture}_test.cc") + } + Invoke-SettingsFixture -Name 'upstream_settings' -Sources @('tests/upstream_settings_test.cc', + 'mods/src/settings/upstream_settings.cc', 'mods/src/settings/warp_mode.cc', + 'mods/src/settings/forbidden_tech.cc') -IncludeDirectories @('tests/upstream_settings_fixture', $JsonInclude) ` + -TestArguments @('docs/settings-coverage.json', (Join-Path $fixtureRoot 'catalog')) + Invoke-SettingsFixture -Name 'runtime_config' -Sources @('tests/runtime_config_test.cc') + Invoke-SettingsFixture -Name 'runtime_config_writer' -Sources @('tests/runtime_config_writer_test.cc') + Invoke-SettingsFixture -Name 'config_save' -Sources @('tests/config_save_test.cc', 'mods/src/config_save.cc') ` + -TestArguments @(Join-Path $fixtureRoot 'created') + Invoke-SettingsFixture -Name 'config_save_failure' -Sources @('tests/config_save_failure_test.cc') ` + -TestArguments @(Join-Path $fixtureRoot 'failures') + Invoke-SettingsFixture -Name 'toml_editor' -Sources @('tests/toml_editor_test.cc', + 'mods/src/toml_editor.cc', 'mods/src/config_save.cc') -TestArguments @(Join-Path $fixtureRoot 'editor') + Invoke-SettingsFixture -Name 'runtime_config_persistence' -Sources @('tests/runtime_config_persistence_test.cc', + 'mods/src/runtime_config_writer.cc', 'mods/src/toml_editor.cc', 'mods/src/config_save.cc') ` + -TestArguments @(Join-Path $fixtureRoot 'reload') + + # Verify actual replacement permissions, including explicit ACLs; only this + # run's newly created fixture files are modified. + $permissionRoot = Join-Path $fixtureRoot 'permissions' + New-Item -ItemType Directory -Path $permissionRoot | Out-Null + $permissionFile = Join-Path $permissionRoot 'settings.toml' + Set-Content -LiteralPath $permissionFile -Value 'enabled = false' + if (-not ((Get-Acl -LiteralPath $permissionFile).Access | Where-Object IsInherited)) { + throw 'Fixture must have inherited permission entries.' + } + $inherited = Get-PermissionState $permissionFile + & (Join-Path $testRoot 'config_save.exe') $permissionRoot + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $permissionFile) -ne $inherited) { + throw 'Inherited ACL regression failed.' + } + $acl = Get-Acl -LiteralPath $permissionFile + $acl.SetAccessRuleProtection($true, $true) + Set-Acl -LiteralPath $permissionFile -AclObject $acl + $explicit = Get-PermissionState $permissionFile + & (Join-Path $testRoot 'config_save.exe') $permissionRoot + if ($LASTEXITCODE -ne 0 -or (Get-PermissionState $permissionFile) -ne $explicit) { + throw 'Explicit ACL regression failed.' + } + + foreach ($mode in @('idle', 'deadline', 'finished', 'missing-handle')) { + $outputPath = Join-Path $fixtureRoot ('force-' + $mode + '.txt') + $timer = [Diagnostics.Stopwatch]::StartNew() + $child = Start-Process -FilePath (Join-Path $testRoot 'runtime_config.exe') ` + -ArgumentList $mode -WindowStyle Hidden -PassThru -RedirectStandardOutput $outputPath + if (-not $child.WaitForExit(5000)) { + $child.Kill() + throw "Force-close fixture stalled: $mode" + } + $child.Refresh() + if ($child.ExitCode -ne 1) { throw "Force-close fixture did not terminate: $mode" } + if ($mode -eq 'deadline' -and ($timer.ElapsedMilliseconds -lt 450 -or + (Get-Content $outputPath -Raw) -notmatch 'pending cancellation requested')) { + throw 'Deadline did not allow best effort and request cancellation.' + } + } + Write-Output 'All local settings fixtures passed, including persistence, ACLs and force-close deadlines.' +} finally { + Pop-Location +} diff --git a/tests/runtime_config_fixture.h b/tests/runtime_config_fixture.h new file mode 100644 index 000000000..e6f97631d --- /dev/null +++ b/tests/runtime_config_fixture.h @@ -0,0 +1,82 @@ +#pragma once +#if _WIN32 +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace spdlog +{ +template void warn(const char*, Args&&...) {} +} // namespace spdlog +namespace config_edit +{ +using Value = std::variant; +enum class Outcome { Saved, AlreadySaved, Conflict, InvalidDocument, Unsupported, IoError }; +// Controllable boundary; the fixture below includes the actual adapter bodies. +struct RuntimeConfigWriter { + struct Status { + std::uint64_t revision = 0; + Outcome outcome = Outcome::AlreadySaved; + bool pending = false, registered = true; + }; + std::uint64_t StatusSequence() const + { return submissions; } + bool Register(std::string, std::string, std::optional) + { return !stopped; } + Status GetStatus(std::string_view, std::string_view) const + { return {submissions, failures ? Outcome::IoError : Outcome::Saved, work, true}; } + bool failures = false; + bool HasFailures() const + { return failures; } + bool HasFailure(std::string_view, std::string_view) const + { return failures; } + bool work = false, stopped = false, finished = false, cancelled = false; + bool block_cancel = false; + unsigned submissions = 0; + void* handle = nullptr; + bool HasWork() const + { return work; } + void RequestCancelPending() + { cancelled = true; } + void Stop(bool cancel) + { + stopped = true; + cancelled = cancel; + if (cancel && block_cancel) { + std::puts("pending cancellation requested"); + std::fflush(stdout); +#if _WIN32 + Sleep(INFINITE); // Deadline must be independent of this stalled caller. +#endif + } + } + bool PollStopped() const + { return finished; } + void* NativeHandle() const + { return handle; } + unsigned Submit(const char*) + { return stopped || failures ? 0 : ++submissions; } + unsigned Submit(const char*, const char*, Value, std::chrono::milliseconds) + { return Submit(""); } +}; +} // namespace config_edit + +// Observe actual callback registration; tests must not manually call an Update +// that the adapter failed to arrange for the no-persistence case. +inline void (*fixture_update_callback)() = nullptr; +inline bool install_screen_manager_update_hook() +{ return true; } +inline bool register_screen_manager_update_callback(void (*callback)()) +{ + fixture_update_callback = callback; + return true; +} diff --git a/tests/runtime_config_persistence_test.cc b/tests/runtime_config_persistence_test.cc new file mode 100644 index 000000000..d3d025821 --- /dev/null +++ b/tests/runtime_config_persistence_test.cc @@ -0,0 +1,95 @@ +#include "config_save.h" +#include "runtime_config_writer.h" +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) +{ + assert(argc == 2); + const auto path = std::filesystem::path(argv[1]) / "community_patch_settings.toml"; + std::filesystem::create_directories(path.parent_path()); + const std::string original = "# keep this comment\n[graphics]\ngalaxy_multi_select = false\n" + "galaxy_label_major_detail = \"native\"\ngalaxy_label_major_threshold = 0.5\n" + "[unrelated]\nvalue = 42\n"; + ReplaceConfigText(path, original); + { + config_edit::RuntimeConfigWriter writer(path); + assert(writer.Register("ui", "keep_base_card_open", std::nullopt)); + assert(writer.Register("control", "primary_requires_visible_target", std::nullopt)); + assert(writer.Register("control", "kirshara_fixes", std::nullopt)); + assert(writer.Register("graphics", "galaxy_multi_select", false)); + assert(writer.Register("graphics", "galaxy_label_major_detail", std::string("native"))); + assert(writer.Register("graphics", "galaxy_label_major_threshold", 0.5)); + assert(writer.Submit("control", "kirshara_fixes", false)); + assert(writer.Submit("ui", "keep_base_card_open", true)); + assert(writer.Submit("control", "primary_requires_visible_target", false)); + assert(writer.Submit("graphics", "galaxy_multi_select", true)); + assert(writer.Submit("graphics", "galaxy_label_major_detail", std::string("threshold"))); + // Quit immediately with a slider write still debounced: orderly stop must flush it. + assert(writer.Submit("graphics", "galaxy_label_major_threshold", 0.95, std::chrono::seconds(30))); + writer.Stop(false); + } + // Fresh parse models a new process, with no access to live Config or writer state. + const auto bytes = ReadConfigText(path); + const auto loaded = toml::parse(bytes); + assert(loaded["graphics"]["galaxy_multi_select"].value() == true); + assert(loaded["graphics"]["galaxy_label_major_detail"].value() == "threshold"); + assert(loaded["graphics"]["galaxy_label_major_threshold"].value() == 0.95); + assert(loaded["ui"]["keep_base_card_open"].value() == true); + const auto kirshara = loaded["control"]["kirshara_fixes"].value(); + assert(kirshara && !*kirshara); + const auto primary = loaded["control"]["primary_requires_visible_target"].value(); + assert(primary && !*primary); + assert(loaded["unrelated"]["value"].value() == 42); + assert(bytes.starts_with("# keep this comment")); + { + config_edit::RuntimeConfigWriter writer(path); + assert(writer.Register("control", "kirshara_fixes", config_edit::Value{*kirshara})); + assert(writer.Register("control", "primary_requires_visible_target", config_edit::Value{*primary})); + assert(writer.Submit("control", "kirshara_fixes", true)); + assert(writer.Submit("control", "primary_requires_visible_target", true)); + writer.Stop(false); + } + const auto restored = toml::parse(ReadConfigText(path)); + assert(restored["control"]["kirshara_fixes"].value() == true); + assert(restored["control"]["primary_requires_visible_target"].value() == true); + assert(restored["unrelated"]["value"].value() == 42); + + // A lazily opened page must retain its startup comparison value, even when + // another page started the worker and the file was subsequently edited. + { + config_edit::RuntimeConfigWriter writer(path); + assert(writer.Register("ui", "keep_base_card_open", true)); + const auto first = writer.Submit("ui", "keep_base_card_open", false); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (writer.LastCompletion().revision < first || writer.HasWork()) { + assert(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + auto external = ReadConfigText(path); + const auto position = external.find("galaxy_label_major_threshold"); + assert(position != std::string::npos); + const auto value_begin = external.find('=', position) + 1; + const auto value_end = external.find_first_of("\r\n#", value_begin); + assert(value_begin && value_end != std::string::npos); + external.replace(value_begin, value_end - value_begin, " 0.85"); + ReplaceConfigText(path, external); + assert(writer.Register("graphics", "galaxy_label_major_threshold", 0.95)); + // A repeated definition is not permission to accept the new disk base. + assert(writer.Register("graphics", "galaxy_label_major_threshold", 0.85)); + assert(writer.Submit("graphics", "galaxy_label_major_threshold", 0.9)); + writer.Stop(false); + while (!writer.PollStopped()) { + assert(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + assert(writer.GetStatus("graphics", "galaxy_label_major_threshold").outcome == config_edit::Outcome::Conflict); + assert(writer.HasFailures()); + assert(toml::parse(ReadConfigText(path))["graphics"]["galaxy_label_major_threshold"].value() == 0.85); + } + std::cout << "Runtime settings disk/reload regression passed\n"; +} diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc new file mode 100644 index 000000000..43fc16677 --- /dev/null +++ b/tests/runtime_config_test.cc @@ -0,0 +1,146 @@ +#define CONFIG_RUNTIME_TEST "runtime_config_fixture.h" +#include "../mods/src/patches/parts/runtime_config.cc" +#include +#include +#include +#include +#include + +namespace +{ +config_edit::RuntimeConfigWriter fixture; +unsigned resumes = 0; +void Reset() +{ + fixture = {}; + writer = &fixture; + available = true; + owner = CurrentThreadToken(); + forcing = false; + persistence_unavailable = false; + reported_save_failure = false; + reported_status_sequence = 0; + adapter_status_sequence = 0; + unavailable_settings.clear(); + save_status_changed = nullptr; + fixture_update_callback = nullptr; + draining = stopped = resume = false; + vote = 0; + resumes = 0; + request_quit = [](int) { ++resumes; }; +} +} // namespace +int main(int argc, char** argv) +{ + Reset(); +#if _WIN32 + if (argc == 2) { + const std::string_view mode(argv[1]); + fixture.work = mode != "idle"; + fixture.block_cancel = mode == "deadline"; + if (mode != "missing-handle") + fixture.handle = CreateEventW(nullptr, TRUE, mode == "finished", nullptr); + runtime_config::ForceClose(); + Sleep(10000); // Parent kills this fixture if the independent deadline fails. + return 9; + } +#endif + unsigned notices = 0; + static unsigned* noticeCount = ¬ices; + assert(runtime_config::SetSaveStatusObserver([] { ++*noticeCount; })); + fixture.failures = true; + std::thread foreignNotice([] { Update(); }); + foreignNotice.join(); + assert(notices == 0 && runtime_config::HasSaveFailures()); + Update(); + Update(); + assert(notices == 1); // One UI callback on a transition, never on each frame. + fixture.failures = false; + Update(); + assert(notices == 2 && !runtime_config::HasSaveFailures()); + available = false; + writer = nullptr; // Configure/Install never supplied the normal update path. + fixture_update_callback = nullptr; + assert(runtime_config::SetSaveStatusObserver(save_status_changed)); + assert(fixture_update_callback); + runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("warp"), {}); + fixture_update_callback(); + assert(notices == 3 && runtime_config::HasSaveFailures()); + available = true; + writer = &fixture; + runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("jump"), {}); + Update(); + assert(notices == 4 && !runtime_config::HasSaveFailures()); // A successful retry clears this key only. + Reset(); + fixture.failures = true; // Transient thread-start failure is tracked by its key. + runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("warp"), {}); + assert(runtime_config::HasSaveFailures() && !persistence_unavailable); + fixture.failures = false; + runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("jump"), {}); + assert(!runtime_config::HasSaveFailures()); + Reset(); + available = false; + runtime_config::SaveSetting("ui", "first", true, {}); + runtime_config::SaveSetting("ui", "second", true, {}); + assert(runtime_config::GetSaveStatus("ui", "first").state == mod_settings::SaveState::Unavailable); + available = true; + runtime_config::SaveSetting("ui", "first", true, {}); + assert(runtime_config::HasSaveFailures()); // Retrying A cannot conceal unavailable B. + assert(runtime_config::GetSaveStatus("ui", "second").state == mod_settings::SaveState::Unavailable); + runtime_config::SaveSetting("ui", "second", true, {}); + assert(!runtime_config::HasSaveFailures()); + Reset(); + assert(runtime_config::RegisterSetting("ui", "auto_confirm_instant_warp")); + assert(!runtime_config::RegisterSetting("", "invalid")); + assert(WantsQuit([] { return true; })); + assert(fixture.stopped && stopped && !draining); + runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("warp"), {}); + assert(fixture.submissions == 0); + Update(); + assert(resumes == 0); + + Reset(); + fixture.work = true; + assert(!WantsQuit([] { return false; })); + assert(!fixture.stopped && !draining); + + Reset(); + fixture.work = true; + assert(!WantsQuit([] { return true; })); + assert(fixture.stopped && draining); + Update(); + assert(resumes == 0); + fixture.work = false; // Disk work done is not yet native worker termination. + Update(); + assert(resumes == 0); + fixture.finished = true; + std::thread foreign([] { + Update(); + runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("jump"), {}); + }); + foreign.join(); + assert(resumes == 0 && fixture.submissions == 0); + request_quit = [](int) { + ++resumes; + assert(!WantsQuit([] { return false; })); // A genuine resumed veto is final. + }; + Update(); + Update(); + assert(resumes == 1); + + Reset(); + fixture.work = true; + assert(!WantsQuit([] { + assert(!WantsQuit([] { return false; })); + return true; // Older outer vote must not replace the later veto. + })); + fixture.finished = true; + Update(); + assert(resumes == 0); + + Reset(); + assert(!WantsQuit([] { return false; })); + runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("warp"), {}); + assert(fixture.submissions == 1); // Veto leaves ordinary save admission open. + std::puts("Native adapter idle/drain/veto/owner fixtures passed"); +} diff --git a/tests/runtime_config_writer_test.cc b/tests/runtime_config_writer_test.cc new file mode 100644 index 000000000..2b11d4eac --- /dev/null +++ b/tests/runtime_config_writer_test.cc @@ -0,0 +1,280 @@ +#include "runtime_config_writer.h" +#include +#include +#include +#include +#include +#include + +using namespace config_edit; +using namespace std::chrono_literals; +namespace +{ +std::mutex gate; +std::condition_variable changed; +bool entered = false, released = false; +Outcome first_result = Outcome::Saved; +std::vector requests; +std::thread::id save_thread; +std::vector save_times; +int reports = 0; +bool block_report = false, release_report = false; +bool fail_thread_start = false; + +void Check(bool value, std::source_location location = std::source_location::current()) +{ + if (!value) + throw std::runtime_error("worker fixture failed at line " + std::to_string(location.line())); +} +Outcome Save(TomlEditor&, const std::filesystem::path&, const Request& request) +{ + std::unique_lock lock(gate); + save_thread = std::this_thread::get_id(); + save_times.push_back(std::chrono::steady_clock::now()); + requests.push_back(request); + if (requests.size() == 1) { + entered = true; + changed.notify_all(); + if (!changed.wait_for(lock, 5s, [] { return released; })) + throw std::runtime_error("fixture timed out"); + return first_result; + } + return Outcome::Saved; +} +void Report(std::string_view, std::string_view, Outcome) +{ + std::unique_lock lock(gate); + ++reports; + changed.notify_all(); + if (block_report) + Check(changed.wait_for(lock, 5s, [] { return release_report; })); +} +void Begin(Outcome result) +{ + entered = released = false; + requests.clear(); + save_times.clear(); + reports = 0; + first_result = result; + block_report = release_report = false; +} +void AwaitSave() +{ + std::unique_lock lock(gate); + Check(changed.wait_for(lock, 5s, [] { return entered; })); +} +void Release() +{ + std::lock_guard lock(gate); + released = true; + changed.notify_all(); +} +void AwaitCompletion(RuntimeConfigWriter& writer, std::uint64_t revision) +{ + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (writer.LastCompletion().revision != revision || writer.HasWork()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } +} +template std::thread StartWorker(Function function, Owner* owner) +{ + if (std::exchange(fail_thread_start, false)) + throw std::runtime_error("fixture thread-start failure"); + return std::thread(function, owner); +} +} // namespace + +// Block at the real worker's save boundary to exercise scheduling deterministically. +#define CONFIG_EDIT_SAVE(editor, path, request) Save(editor, path, request) +#define CONFIG_EDIT_START_WORKER(...) StartWorker(__VA_ARGS__) +#include "../mods/src/runtime_config_writer.cc" + +int main() +{ + try { + for (auto outcome : {Outcome::Saved, Outcome::AlreadySaved, Outcome::Conflict, Outcome::IoError}) { + Begin(outcome); + RuntimeConfigWriter writer("unused", Report); + Check(writer.Register("ui", "auto_confirm_instant_warp", Value{std::string("none")})); + Check(writer.Submit("ui", "unregistered", std::string("invalid")) == 0); + Check(writer.Submit("ui", "auto_confirm_instant_warp", std::string("warp")) == 1); + AwaitSave(); + Check(writer.HasWork()); + Check(writer.GetStatus("ui", "auto_confirm_instant_warp").pending); + const auto status_sequence = writer.StatusSequence(); + // A duplicated definition never replaces the acknowledged comparison base. + Check(writer.Register("ui", "auto_confirm_instant_warp", std::string("external"))); + Check(writer.Submit("ui", "auto_confirm_instant_warp", std::string("jump")) == 2); + Check(writer.Submit("ui", "auto_confirm_instant_warp", std::string("none")) == 3); + writer.Stop(false); + Check(!writer.PollStopped()); + Check(writer.Submit("ui", "auto_confirm_instant_warp", std::string("warp")) == 0); + Release(); + auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(!writer.HasWork()); + Check(requests.size() == 2); + Check(requests[1].desired == Value{std::string("none")}); + const bool success = outcome == Outcome::Saved || outcome == Outcome::AlreadySaved; + Check(requests[1].expected == std::optional{std::string(success ? "warp" : "none")}); + Check(reports == (success ? 0 : 1)); + Check(save_thread != std::this_thread::get_id()); + Check(writer.LastCompletion().revision == 3); + Check(writer.LastCompletion().outcome == Outcome::Saved); + const auto status = writer.GetStatus("ui", "auto_confirm_instant_warp"); + Check(status.revision == 3 && !status.pending && status.outcome == Outcome::Saved); + Check(writer.StatusSequence() > status_sequence); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused"); + Check(writer.Register("ui", "auto_confirm_instant_warp", Value{std::string("none")})); + writer.Submit("ui", "auto_confirm_instant_warp", std::string("warp")); + AwaitSave(); + writer.Submit("ui", "auto_confirm_instant_warp", std::string("jump")); + writer.Stop(false); // Force-close may arrive during an orderly drain. + writer.RequestCancelPending(); + Check(writer.Submit("ui", "auto_confirm_instant_warp", std::string("none")) == 0); + // Hold the force-close caller before Stop(true), while the active save + // completes. Publication alone must prevent the queued save from starting. + Release(); + auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(requests.size() == 1); + Check(writer.LastCompletion().revision == 2); + Check(writer.LastCompletion().outcome == Outcome::Cancelled); + writer.Stop(true); + } + Begin(Outcome::IoError); + { + RuntimeConfigWriter writer("unused", Report); + Check(writer.Register("ui", "auto_confirm_instant_warp", Value{std::string("none")})); + Check(writer.Register("graphics", "threshold", Value{0.5})); + const auto failed = writer.Submit("ui", "auto_confirm_instant_warp", std::string("warp")); + AwaitSave(); + Release(); + AwaitCompletion(writer, failed); + Check(writer.HasFailures()); + Check(writer.GetStatus("ui", "auto_confirm_instant_warp").outcome == Outcome::IoError); + AwaitCompletion(writer, writer.Submit("graphics", "threshold", 0.7)); + Check(writer.HasFailures()); // Saving B cannot hide A's failed save. + Check(writer.GetStatus("graphics", "threshold").outcome == Outcome::Saved); + Check(writer.GetStatus("ui", "auto_confirm_instant_warp").outcome == Outcome::IoError); + AwaitCompletion(writer, writer.Submit("ui", "auto_confirm_instant_warp", std::string("jump"))); + Check(!writer.HasFailures()); // A later successful save of A clears it. + Check(reports == 1); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused"); + Check(writer.Register("ui", "auto_confirm_instant_warp", Value{std::string("none")})); + fail_thread_start = true; + Check(!writer.Submit("ui", "auto_confirm_instant_warp", std::string("warp"))); + Check(writer.HasFailure("ui", "auto_confirm_instant_warp") && writer.HasFailures() && !writer.HasWork()); + Check(!writer.HasFailure("other", "unregistered")); + const auto retry = writer.Submit("ui", "auto_confirm_instant_warp", std::string("jump")); + AwaitSave(); + Release(); + AwaitCompletion(writer, retry); + Check(!writer.HasFailures() && !writer.HasFailure("ui", "auto_confirm_instant_warp")); + } + Begin(Outcome::IoError); + { + block_report = true; + RuntimeConfigWriter writer("unused", Report); + Check(writer.Register("ui", "auto_confirm_instant_warp", Value{std::string("none")})); + writer.Submit("ui", "auto_confirm_instant_warp", std::string("warp")); + AwaitSave(); + Release(); + { + std::unique_lock lock(gate); + Check(changed.wait_for(lock, 5s, [] { return reports == 1; })); + Check(writer.HasWork()); // Logging still executing must not look idle. + writer.Stop(false); + Check(!writer.PollStopped()); + release_report = true; + changed.notify_all(); + } + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused"); + Check(writer.Register("ui", "auto_confirm_instant_warp", Value{std::string("none")})); + Check(writer.Register("graphics", "threshold", Value{0.5})); + Check(!writer.Submit("other", "unregistered", true)); + writer.Submit("ui", "auto_confirm_instant_warp", std::string("warp")); + AwaitSave(); + Check(writer.Register("graphics", "late", Value{true})); // Lazy controls register after first use. + writer.Submit("graphics", "threshold", 0.6, 150ms); + writer.Submit("ui", "auto_confirm_instant_warp", std::string("jump")); + writer.Submit("graphics", "threshold", 0.7, 150ms); + writer.Stop(false); // Drain also flushes a slider whose delay has not expired. + Release(); + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(requests.size() == 3); + Check(requests[1].key == "auto_confirm_instant_warp" && requests[1].desired == Value{std::string("jump")}); + Check(requests[1].expected == std::optional{std::string("warp")}); + Check(requests[2].key == "threshold" && requests[2].desired == Value{0.7}); + Check(requests[2].expected == std::optional{0.5}); + } + Begin(Outcome::Saved); + std::chrono::steady_clock::time_point initial_submitted, replacement_submitted; + { + RuntimeConfigWriter writer("unused"); + Check(writer.Register("graphics", "threshold", Value{0.5})); + initial_submitted = std::chrono::steady_clock::now(); + writer.Submit("graphics", "threshold", 0.6, 300ms); + { + std::unique_lock lock(gate); + changed.wait_for(lock, 75ms, [] { return entered; }); + } + replacement_submitted = std::chrono::steady_clock::now(); + const auto revision = writer.Submit("graphics", "threshold", 0.7, 300ms); + Release(); + AwaitCompletion(writer, revision); // Ordinary expiration, without Stop/quit flushing the delay. + } + // The test thread may resume after the first deadline on a busy runner. + // Validate actual entry times, not a negative assertion made by a late observer. + // Both an already-active first write and a coalesced replacement are valid; + // the gated test above independently requires queued same-key coalescing. + Check(requests.size() == 1 || requests.size() == 2); + Check(requests.back().desired == Value{0.7}); + if (requests.size() == 2) + Check(requests.front().desired == Value{0.6}); + for (std::size_t i = 0; i < requests.size(); ++i) { + const auto submitted = requests[i].desired == Value{0.6} ? initial_submitted : replacement_submitted; + Check(save_times[i] >= submitted + 300ms); + } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused"); + Check(writer.Register("graphics", "threshold", Value{0.5})); + writer.Submit("graphics", "threshold", 0.9, 10s); + writer.Stop(true); // Cancellation must wake a delayed writer promptly. + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(requests.empty()); + } + RuntimeConfigWriter idle("unused"); + idle.Stop(false); + Check(idle.PollStopped()); + std::cout << "Runtime writer coalescing/drain/cancel fixtures passed\n"; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/tests/settings_maintenance_test.cc b/tests/settings_maintenance_test.cc new file mode 100644 index 000000000..eebc78390 --- /dev/null +++ b/tests/settings_maintenance_test.cc @@ -0,0 +1,137 @@ +#include "settings/maintenance.h" +#include +#include + +namespace +{ +std::optional editor; +} +namespace mod_settings +{ +bool OpenTextEntry(TextEntryRequest request) +{ + editor = std::move(request); + return true; +} +} // namespace mod_settings + +int main() +{ + using namespace mod_settings; + unsigned writes = 0, saves = 0; + bool enabled = true, available = true; + bool numeric_available = true; + float number = 0.75f; + SaveStatus boolean_save, numeric_save; + ValueDefinition boolean{"feature", "Feature", + [&] { return available ? ReadResult::Known(enabled, 1) : ReadResult{}; }, + [&](bool value, std::uint64_t) { + ++writes; + enabled = value; + return ApplyResult::Applied; + }}; + boolean.metadata = {"ui", "enabled", "Feature help"}; + boolean.default_value = false; + boolean.persist = [&](const bool&) { + ++saves; + boolean_save.state = SaveState::Pending; + }; + boolean.save_status = [&] { return boolean_save; }; + BooleanSetting toggle(std::move(boolean)); + ValueDefinition numeric{ + "number", "Number", + [&] { return numeric_available ? ValueReadResult::Known(number, 1) : ValueReadResult{}; }, + [&](float value, std::uint64_t) { + ++writes; + number = value; + return ApplyResult::Applied; + }}; + numeric.metadata = {"graphics", "threshold", "Number help", "ratio"}; + numeric.default_value = 0.5f; + numeric.persist = [&](const float&) { + ++saves; + numeric_save.state = SaveState::Pending; + }; + numeric.save_status = [&] { return numeric_save; }; + ValueSetting canonical(numeric); + SliderSetting slider(std::move(numeric), 0, 1, 0.01f, [] { return true; }); + + ActionSetting exact{"number.exact", "Exact number", + [](std::size_t) { return ActionSetting::Presentation{"Exact number", "Edit", ""}; }, + [](std::size_t) {}}; + exact.metadata = &canonical.metadata(); + exact.reset = [&] { canonical.Reset(canonical.Observe()); }; + exact.prepare_reset = [&] { return PrepareReset(canonical); }; + exact.retry = [&] { canonical.RetrySave(canonical.Observe()); }; + exact.default_label = [] { return "0.5"; }; + exact.status = [&] { return canonical.status_text(); }; + exact.save_status = [&] { return canonical.save_status(); }; + PageCatalog catalog("community_mod.settings", "Mod Settings"); + assert(catalog.AddPage("page", "Page", "community_mod.settings") == Registration::Added); + catalog.AddBoolean("page", toggle); + catalog.AddSlider("page", slider); + catalog.AddAction("page", exact); // Both representations describe one saved setting. + RegisterMaintenancePages(catalog); + auto pages = catalog.Build(); + const auto page = std::ranges::find(pages, "page.defaults", &PageCatalog::Page::id); + assert(page != pages.end()); + assert(page->items.size() == 5); // Page reset + two (details, retry) pairs. + assert(writes == 0 && saves == 0); + for (const auto& item : page->items) + std::get(item)->Read(0); + assert(writes == 0 && saves == 0); // Browsing details/status never normalizes config. + + auto* page_reset = std::get(page->items[0]); + available = false; + page_reset->invoke(0); + assert(editor->help.find("1 available setting(s)") != std::string::npos); + assert(editor->help.find("1 unavailable setting(s)") != std::string::npos); + assert(editor->commit("") && enabled && number == 0.5f && writes == 1 && saves == 1); + numeric_available = false; + page_reset->invoke(0); + assert(!editor->commit("") && writes == 1 && saves == 1); // Nothing available is not a successful reset. + numeric_available = true; + available = true; + number = 0.75f; + writes = saves = 0; + numeric_save = {}; + page_reset->invoke(0); + assert(editor && writes == 0 && saves == 0); + // A value changing while confirmation is open invalidates the whole page; + // the other setting must not be reset first. + number = 0.9f; + assert(!editor->commit("") && enabled && number == 0.9f && writes == 0 && saves == 0); + page_reset->invoke(0); + available = false; + assert(!editor->commit("") && writes == 0 && saves == 0); + available = true; + page_reset->invoke(0); + toggle.InvalidateSession(); + assert(!editor->commit("") && writes == 0 && saves == 0); + page_reset->invoke(0); + assert(editor->commit("") && !enabled && number == 0.5f && writes == 2 && saves == 2); + + auto* details = std::get(page->items[1]); + enabled = true; + details->invoke(0); + assert(editor->help.find("ui.enabled") != std::string::npos); + assert(editor->help.find("Default: Off") != std::string::npos); + toggle.InvalidateSession(); + assert(!editor->commit("") && enabled && writes == 2); + + auto* boolean_retry = std::get(page->items[2]); + auto* numeric_retry = std::get(page->items[4]); + boolean_save.state = SaveState::Failed; + numeric_save.state = SaveState::Saved; + assert(boolean_retry->Read(0).visible && !numeric_retry->Read(0).visible); + boolean_retry->invoke(0); + assert(saves == 3 && writes == 2); // A retry persists this key without replaying application. + assert(boolean_save.state == SaveState::Pending && numeric_save.state == SaveState::Saved); + assert(!boolean_retry->Read(0).visible); + number = 2.0f; // Valid canonical value outside the convenience slider's range. + assert(!slider.state().Observe().state.known() && canonical.Observe().state.known()); + auto* numeric_details = std::get(page->items[3]); + numeric_details->invoke(0); + assert(editor->commit("") && number == 0.5f && writes == 3 && saves == 4); + std::cout << "Maintenance browse/dedup/reset/stale-session/per-key retry fixtures passed\n"; +} diff --git a/tests/settings_metadata_test.cc b/tests/settings_metadata_test.cc new file mode 100644 index 000000000..1ca3b99e7 --- /dev/null +++ b/tests/settings_metadata_test.cc @@ -0,0 +1,81 @@ +#include "settings/value_settings.h" +#include +#include + +using namespace mod_settings; + +int main() +{ + bool requested = false, active = false, available = true, reject = false, uncertain = false; + unsigned writes = 0, saves = 0; + SaveStatus disk; + ValueDefinition definition{ + "test.feature", "Feature", + [&] { return available ? ValueReadResult::Known(requested, 1) : ValueReadResult{}; }, + [&](bool value, std::uint64_t generation) { + assert(generation == 1); + ++writes; + if (reject) + return ApplyResult::Rejected; + requested = value; + if (uncertain) + available = false; + return ApplyResult::Applied; + }}; + definition.metadata = {"ui", "feature", "A description", "", ApplyTiming::Restart}; + definition.default_value = false; + definition.persist = [&](const bool& value) { + assert(value == requested); + disk = {SaveState::Pending, ++saves}; + }; + definition.save_status = [&] { return disk; }; + definition.active_value = [&] { return std::optional(active); }; + ValueSetting setting(std::move(definition)); + assert(setting.metadata().TomlKey() == "ui.feature"); + assert(setting.default_value() == false); + const auto before = setting.Observe(); + setting.Observe(); + assert(writes == 0 && saves == 0 && !setting.restart_pending()); + { + ValueSetting::RenderScope rendering(setting); + assert(setting.Reset(before).outcome == Outcome::Suppressed); + assert(setting.RetrySave(before).outcome == Outcome::Suppressed); + } + assert(saves == 0); + auto changed = setting.SetFromUser(true, before); + assert(changed.outcome == Outcome::AppliedVerified && writes == 1 && saves == 1); + assert(setting.restart_pending() && setting.save_status().state == SaveState::Pending); + assert(setting.status_text() == "Saving...; restart required"); + assert(setting.Reset(before).outcome == Outcome::Conflict && saves == 1); + disk.state = SaveState::Conflict; + assert(setting.save_status().state == SaveState::Conflict); + assert(setting.status_text() == "Not saved; file changed externally; restart required"); + assert(setting.RetrySave(changed.snapshot).outcome == Outcome::Unchanged); + assert(writes == 1 && saves == 2 && setting.save_status().state == SaveState::Pending); + auto reset = setting.Reset(setting.Observe()); + assert(reset.outcome == Outcome::AppliedVerified && !requested && !setting.restart_pending()); + assert(writes == 2 && saves == 3); + reject = true; + assert(setting.SetFromUser(true, setting.Observe()).outcome == Outcome::Rejected); + assert(saves == 3); + reject = false; + uncertain = true; + assert(setting.SetFromUser(true, setting.Observe()).outcome == Outcome::Unverified); + assert(saves == 3); // Unverified publication must never schedule a save. + assert(setting.status_text().empty()); // Do not advertise an old save as applied. + available = true; + setting.InvalidateSession(); + assert(setting.RetrySave(reset.snapshot).outcome == Outcome::Conflict && saves == 3); + + bool value = false; + ValueDefinition throwing{"throw", "Throw", [&] { return ValueReadResult::Known(value, 1); }, + [&](bool desired, std::uint64_t) { + value = desired; + return ApplyResult::Applied; + }}; + throwing.persist = [](const bool&) { throw std::runtime_error("persistence"); }; + ValueSetting second(std::move(throwing)); + assert(second.SetFromUser(true, second.Observe()).outcome == Outcome::AppliedVerified); + assert(second.save_status().state == SaveState::Unavailable); + std::cout << "Setting metadata/reset/retry/persistence-readback fixtures passed\n"; +} diff --git a/tests/settings_search_test.cc b/tests/settings_search_test.cc new file mode 100644 index 000000000..540fce7a3 --- /dev/null +++ b/tests/settings_search_test.cc @@ -0,0 +1,72 @@ +#include "settings/settings_search.h" +#include +#include +using namespace mod_settings; +int main() +{ + int reads = 0, writes = 0; + const auto definition = [&](std::string id, std::string label, SettingMetadata metadata) { + Definition result{std::move(id), std::move(label), + [&] { + ++reads; + return ReadResult::Known(true, 1); + }, + [&](bool, std::uint64_t) { + ++writes; + return ApplyResult::Applied; + }}; + result.metadata = std::move(metadata); + return result; + }; + BooleanSetting cargo( + definition("legacy.presentation.counter", "Instant ship cargo counter", + {"ui", "instant_cargo_counter", "Remove the animation delay from the capacity counter"})); + BooleanSetting hidden(definition("anything", "Hostile cargo", {"ui", "show_hostile_cargo"})); + BooleanSetting labels( + definition("legacy.presentation.player", "Player label visibility", {"graphics", "zoom_label_player_detail"})); + BooleanSetting native(definition("native.preference", "Native preference", {})); + ActionSetting exact{"exact", "Precise counter", [](std::size_t) { return ActionSetting::Presentation{}; }, + [](std::size_t) {}}; + exact.metadata = &cargo.metadata(); + ActionSetting change{"shortcut.change", "Change", [](std::size_t) { return ActionSetting::Presentation{}; }, + [](std::size_t) {}}; + ActionSetting remove{"shortcut.remove", "Remove", [](std::size_t) { return ActionSetting::Presentation{}; }, + [](std::size_t) {}}; + const SettingMetadata shortcutMetadata{"shortcuts", "move_left", "Move the camera to the left"}; + PageCatalog pages("community_mod.settings", "Mod Settings"); + pages.AddPage("community_mod.previews", "Previews & Cargo", "community_mod.settings"); + pages.AddHeading("community_mod.previews", "cargo.targets", "Target types", false, [] { return false; }); + pages.AddBoolean("community_mod.previews", hidden); + pages.AddBoolean("community_mod.previews", cargo); + pages.AddAction("community_mod.previews", exact); + pages.AddBoolean("community_mod.previews", native); + pages.AddPage("community_mod.labels", "Fleet Labels", "community_mod.settings"); + pages.AddHeading("community_mod.labels", "player", "Player", true); + pages.AddBoolean("community_mod.labels", labels); + pages.AddPage("community_mod.shortcuts", "Shortcuts", "community_mod.settings"); + pages.AddPage("community_mod.shortcuts.camera", "Camera", "community_mod.shortcuts"); + pages.AddPage("community_mod.shortcuts.move_left", "Pan left", "community_mod.shortcuts.camera"); + pages.SetMetadata("community_mod.shortcuts.move_left", shortcutMetadata); + pages.AddAction("community_mod.shortcuts.move_left", change); + pages.AddAction("community_mod.shortcuts.move_left", remove); + SettingsSearch search; + const auto plan = pages.Build(); + search.Build(plan); + assert(search.Find("").empty()); + assert(search.Find(" ").empty()); + assert(search.Find("no such thing").empty()); + assert(search.Find("UI.INSTANT_CARGO_COUNTER").size() == 1); + assert(search.Find("instant cargo").front()->item == cargo.id()); + assert(search.Find("capacity animation").front()->item == cargo.id()); + assert(search.Find("zoom_label_player_detail").front()->item == labels.id()); + assert(search.Find("fleet player visibility").front()->item == labels.id()); + assert(search.Find("hostile cargo").front()->item == hidden.id()); // Discoverable while its parent is off. + assert(search.Find("shortcuts move_left").size() == 1); // One hit per model, never per editor command. + assert(search.Find("pan left").front()->key == "shortcuts.move_left"); + assert(search.Find("pan left").front()->location == "Shortcuts > Camera > Pan left"); + assert(std::ranges::none_of(search.Find("Remove"), [](const auto* entry) { return entry->label == "Remove"; })); + assert(search.Find("Native preference").size() == 1); + assert(search.Find("legacy.presentation.counter").empty()); // Keys come from definitions, never guessed from IDs. + assert(reads == 0 && writes == 0); + std::cout << "Settings search tests passed\n"; +} diff --git a/tests/shortcut_editor_test.cc b/tests/shortcut_editor_test.cc new file mode 100644 index 000000000..544ed424d --- /dev/null +++ b/tests/shortcut_editor_test.cc @@ -0,0 +1,343 @@ +#include "settings/shortcut_capture.h" +#include "settings/shortcut_catalog.h" +#include "settings/shortcut_draft.h" +#include "settings/shortcut_popup_keys.h" +#include "settings/shortcut_undo.h" +#include +#include + +using namespace mod_settings; +void Check(bool ok, const char* label) +{ + if (!ok) { + std::cerr << label << '\n'; + std::exit(1); + } +} +int main() +{ + constexpr auto limit = ShortcutBindingDisplayLimit; + Check(ShortcutCountFitsEdit(limit - 1, limit) && !ShortcutCountFitsEdit(limit, limit + 1), + "native row budget rejects oversized growth before publication"); + Check(ShortcutCountFitsEdit(62, 62) && ShortcutCountFitsEdit(62, 61) && !ShortcutCountFitsEdit(62, 63), + "existing oversized TOML lists can be rebound/reduced but not grown in UI"); + Check(VisibleShortcutBindingCount(0) == 0 && VisibleShortcutBindingCount(1) == 1, + "empty/single-binding pages have no extra binding rows"); + for (std::size_t count : {61u, 62u, 1000u}) { + Check(VisibleShortcutBindingCount(count) == limit, + "existing long lists present a bounded prefix without dropping other settings pages"); + Check(2 * VisibleShortcutBindingCount(count) + ShortcutFixedRowLimit <= PageCatalog::NativeChildLimit, + "Change and Remove plus fixed rows fit native page capacity"); + } + Check(DescribeShortcut(ShowInventory, "show_inventory").group == ShortcutGroup::Screens + && DescribeShortcut(ShowArtifacts, "show_artifacts").group == ShortcutGroup::Screens, + "opening inventory/artifacts belongs to game screens"); + Check(DescribeShortcut(ToggleAutoConfirmInstantWarp, "toggle_instant_warp").group == ShortcutGroup::Travel, + "instant warp shortcut belongs to map and travel"); + Check(DescribeShortcut(Restart, "restart").label == "Clear localization cache and reload", + "cache-clearing shortcut must not promise a plain restart"); + Check(DescribeShortcut(Quit, "quit").label == "Force close client" && !ShortcutExplanation(Quit).empty(), + "force close must be identified before binding it"); + + // A sparse override list models adding an action without editing UI metadata. + // Discovery depends on registered keys, never on whether a binding is present. + constexpr auto partial = std::to_array({ + {MoveRight, ShortcutGroup::Camera, "Pan right"}, + {ShowArtifacts, ShortcutGroup::Screens, "Open artifacts"}, + }); + static_assert(ValidShortcutCatalog(partial)); + constexpr auto duplicates = std::to_array({ + {MoveRight, ShortcutGroup::Camera, "Pan right"}, + {MoveRight, ShortcutGroup::Travel, "Duplicate"}, + }); + static_assert(!ValidShortcutCatalog(duplicates)); + constexpr auto invalidAction = std::to_array({{GameFunction::Max, ShortcutGroup::Camera, "Invalid"}}); + constexpr auto invalidGroup = std::to_array({{MoveRight, static_cast(99), "Invalid"}}); + constexpr auto emptyLabel = std::to_array({{MoveRight, ShortcutGroup::Camera, ""}}); + static_assert(!ValidShortcutCatalog(invalidAction) && !ValidShortcutCatalog(invalidGroup) + && !ValidShortcutCatalog(emptyLabel)); + std::array registered{}; + registered[MoveLeft] = "toggle_new_feature"; + registered[MoveRight] = "move_right"; + registered[MoveUp] = "test_value_2"; + auto key = [&](GameFunction action) { return registered[action]; }; + const auto discovered = DiscoverShortcuts(key, partial); + Check(discovered.size() == 3 && discovered[0].action == MoveRight && discovered[0].label == "Pan right" + && !discovered[0].fallback && discovered[0].group == ShortcutGroup::Camera, + "registered actions keep explicit labels and categories; metadata alone cannot expose an unregistered action"); + Check(discovered[1].action == MoveUp && discovered[1].label == "Test value 2" && discovered[1].fallback + && discovered[2].action == MoveLeft && discovered[2].label == "Toggle new feature" + && discovered[2].group == ShortcutGroup::Uncategorized, + "uncatalogued registered actions are discovered and sorted with readable labels"); + const auto promoted = std::to_array({{MoveLeft, ShortcutGroup::Interface, "Toggle polished feature"}}); + const auto description = DescribeShortcut(MoveLeft, registered[MoveLeft], promoted); + Check(description.action == discovered[2].action && description.group == ShortcutGroup::Interface + && description.label == "Toggle polished feature" && !description.fallback, + "a later override changes presentation without changing action identity"); + registered[MoveLeft] = {}; + Check(DiscoverShortcuts(key, partial).size() == 2, + "startup availability filtering excludes actions before fallback discovery"); + registered = {}; + Check(DiscoverShortcuts(key, partial).empty(), "an empty registry cannot create fallback editors"); + Check(ShortcutGroups[ShortcutGroups.size() - 2].group == ShortcutGroup::Uncategorized + && ShortcutGroups.back().group == ShortcutGroup::Diagnostics, + "Uncategorized precedes Diagnostics at the bottom"); + Check(HumanizeShortcutKey("__open--panel_2__") == "Open panel 2" && HumanizeShortcutKey("__") == "Shortcut", + "fallback labels collapse separators, retain numbers and never become blank"); + + registered = {}; + registered[MoveUp] = "move_up"; + registered[MoveDown] = "move_down"; + registered[MoveLeft] = "move_left"; + registered[MoveRight] = "move_right"; + const auto movement = DiscoverShortcuts(key); + Check(movement.size() == 4, "all registered movement actions have in-game editors"); + for (const auto& entry : movement) + Check(entry.group == ShortcutGroup::Camera && !entry.fallback && !ShortcutExplanation(entry.action).empty(), + "movement editors have Camera labels and help text"); + + ShortcutList live{"LCTRL-G", "F8"}; + int writes = 0; + ValueSetting owner({"shortcuts.test", "Test", + [&] { return ValueReadResult::Known(live, 1); }, + [&](ShortcutList value, std::uint64_t) { + ++writes; + live = value; + return ApplyResult::Applied; + }}); + ShortcutDraft draft(owner); + draft.Begin(); + draft.Stage(1, "ALT-I"); + Check(writes == 0 && live == ShortcutList{"LCTRL-G", "F8"}, "recording must not write"); + Check(draft.Apply() == Outcome::AppliedVerified && live == ShortcutList{"LCTRL-G", "ALT-I"}, + "replace preserves alternatives and sided modifiers"); + draft.Begin(); + draft.Stage(2, "F9"); + draft.Cancel(); + Check(draft.Apply() == Outcome::Suppressed && writes == 1, "cancel never writes"); + draft.Begin(); + draft.Stage(2, "F9"); + Check(draft.Apply() == Outcome::AppliedVerified && live.size() == 3, "append preserves previous alternatives"); + draft.Begin(); + live = {"F10"}; + draft.Stage(0, "F7"); + Check(draft.Apply() == Outcome::Conflict && live == ShortcutList{"F10"}, + "change during capture cannot be overwritten"); + draft.Begin(); + draft.Stage(0, {}); + Check(draft.Apply() == Outcome::AppliedVerified && live.empty(), "explicit removal can unbind last key"); + draft.Begin(); + draft.Stage(0, "CTRL-I"); + Check(draft.Apply() == Outcome::AppliedVerified && live == ShortcutList{"CTRL-I"}, "unbound action can be rebound"); + const auto beforeDuplicates = writes; + draft.Begin(); + Check(draft.Stage(1, "CTRL-I") == ShortcutStage::AlreadyBound && !draft.pending(), + "append must reject an existing binding"); + Check(draft.Apply() == Outcome::Suppressed && writes == beforeDuplicates, + "duplicate append must not publish or save"); + draft.Begin(); + Check(draft.Stage(0, "CTRL-I") == ShortcutStage::AlreadyBound && !draft.pending(), + "re-recording the selected binding is a no-op"); + live = {"SHIFT-I", "ALT-I"}; + draft.Begin(); + Check(draft.Stage(1, "F8") == ShortcutStage::Staged && draft.pending(), "stage another alternative"); + Check(draft.Stage(1, "SHIFT-I") == ShortcutStage::AlreadyBound && !draft.pending(), + "duplicate replacement clears any preceding draft"); + Check(draft.Apply() == Outcome::Suppressed && writes == beforeDuplicates && live == ShortcutList{"SHIFT-I", "ALT-I"}, + "duplicate replacement preserves all alternatives without writing"); + live = {"SHIFT-I", "SHIFT-I", "ALT-I"}; + draft.Begin(); + Check(writes == beforeDuplicates && live.size() == 3, "opening does not clean up existing duplicates"); + Check(draft.Stage(0, {}) == ShortcutStage::Staged && draft.Apply() == Outcome::AppliedVerified + && live == ShortcutList{"SHIFT-I", "ALT-I"} && writes == beforeDuplicates + 1, + "explicit removal can clean up one existing duplicate without losing the shortcut"); + const auto beforeRestore = writes; + draft.Begin(); + Check(draft.Restore({"F8", "CTRL-G"}) == ShortcutStage::Staged && writes == beforeRestore, + "restoring defaults stages a full action without writing"); + draft.Cancel(); + Check(draft.Apply() == Outcome::Suppressed && writes == beforeRestore, + "cancelling default restoration preserves current bindings"); + draft.Begin(); + draft.Restore({"F8", "CTRL-G"}); + live = {"F10"}; + Check(draft.Apply() == Outcome::Conflict && live == ShortcutList{"F10"}, + "restoration cannot overwrite an action changed since staging"); + draft.Begin(); + Check(draft.Restore({}) == ShortcutStage::Staged && draft.Apply() == Outcome::AppliedVerified && live.empty(), + "a NONE default explicitly unbinds the action"); + draft.Begin(); + Check(draft.Restore({}) == ShortcutStage::AlreadyBound && !draft.pending(), + "restoring an already active default is a no-op"); + ShortcutUndo removal(owner); + live = {"LCTRL-G", "F8", "LCTRL-G"}; + const auto beforeRemoval = writes; + Check(removal.Remove(1) == Outcome::AppliedVerified && writes == beforeRemoval + 1 + && live == ShortcutList{"LCTRL-G", "LCTRL-G"} && removal.available() && removal.message() == "Removed F8", + "Remove writes immediately without a pending Apply and offers the removed key for Undo"); + Check(removal.Undo() == Outcome::AppliedVerified && writes == beforeRemoval + 2 + && live == ShortcutList{"LCTRL-G", "F8", "LCTRL-G"} && !removal.available(), + "Undo writes immediately and restores ordering, sided modifiers and existing duplicates exactly"); + Check(removal.Undo() == Outcome::Suppressed && writes == beforeRemoval + 2, "repeated Undo cannot write again"); + live = {"F8"}; + Check(removal.Remove(0) == Outcome::AppliedVerified && live.empty() && removal.Undo() == Outcome::AppliedVerified + && live == ShortcutList{"F8"}, + "removing the last binding makes the action unbound and remains undoable"); + removal.Remove(0); + live = {"F9"}; + const auto beforeStaleUndo = writes; + Check(removal.Undo() == Outcome::Conflict && live == ShortcutList{"F9"} && writes == beforeStaleUndo + && !removal.available(), + "stale Undo cannot overwrite a newer binding or persist it"); + live = {"F7", "F8", "F9"}; + removal.Remove(0); + removal.Remove(0); + Check(removal.message() == "Removed F8" && removal.Undo() == Outcome::AppliedVerified + && live == ShortcutList{"F8", "F9"}, + "Undo belongs to the most recent removal only"); + Check(removal.Remove(99) == Outcome::Rejected && !removal.available(), "invalid removal cannot offer Undo"); + removal.Remove(0); + removal.Clear(); + const auto afterClear = writes; + Check(removal.Undo() == Outcome::Suppressed && writes == afterClear, + "leaving the editor or starting another edit clears Undo without changing bindings"); + live = {"LEFT", "N"}; + const auto beforeDefault = writes; + Check(removal.Restore({"LEFT"}) == Outcome::AppliedVerified && live == ShortcutList{"LEFT"} + && writes == beforeDefault + 1 && removal.message() == "Restored defaults" && removal.available(), + "Restore replaces the complete list and persists immediately, without pending Apply"); + Check(removal.Undo() == Outcome::AppliedVerified && live == ShortcutList{"LEFT", "N"} && writes == beforeDefault + 2, + "Undo default restoration immediately restores every previous alternative"); + Check(removal.Restore({}) == Outcome::AppliedVerified && live.empty() && removal.Undo() == Outcome::AppliedVerified + && live == ShortcutList{"LEFT", "N"}, + "NONE defaults apply immediately and can be undone"); + const auto beforeNoopRestore = writes; + Check(removal.Restore(live) == Outcome::Unchanged && writes == beforeNoopRestore && !removal.available(), + "restoring an already matching default neither writes nor advertises Undo"); + removal.Restore({"LEFT"}); + live = {"RIGHT"}; + const auto beforeRestoreConflict = writes; + Check(removal.Undo() == Outcome::Conflict && live == ShortcutList{"RIGHT"} && writes == beforeRestoreConflict, + "Undo restoration cannot overwrite a subsequent edit"); + { + ShortcutList oversized(limit + 2, "F8"); + ShortcutUndo* activeRemoval = nullptr; + bool reject = false; + ValueSetting boundedOwner({"shortcuts.large", "Large", + [&] { return ValueReadResult::Known(oversized, 1); }, + [&](ShortcutList value, std::uint64_t) { + if (reject + || (!ShortcutCountFitsEdit(oversized.size(), value.size()) + && !(activeRemoval && activeRemoval->Restoring(value)))) + return ApplyResult::Rejected; + oversized = std::move(value); + return ApplyResult::Applied; + }}); + ShortcutUndo largeRemoval(boundedOwner); + activeRemoval = &largeRemoval; + Check(largeRemoval.Remove(0) == Outcome::AppliedVerified && oversized.size() == limit + 1 + && largeRemoval.Undo() == Outcome::AppliedVerified && oversized.size() == limit + 2, + "oversized TOML lists permit an exact removal undo"); + auto addition = oversized; + addition.push_back("F9"); + Check(boundedOwner.SetFromUser(addition, boundedOwner.Observe()).outcome == Outcome::Rejected, + "Undo does not grant ordinary additions permission to exceed the row budget"); + Check(largeRemoval.Restore({"LEFT"}) == Outcome::AppliedVerified && largeRemoval.Undo() == Outcome::AppliedVerified + && oversized.size() == limit + 2, + "Undo default restoration can recover the original oversized player list"); + reject = true; + Check(largeRemoval.Remove(0) == Outcome::Rejected && !largeRemoval.available() && oversized.size() == limit + 2, + "failed removal cannot offer success or Undo"); + Check(largeRemoval.Restore({"LEFT"}) == Outcome::Rejected && !largeRemoval.available() + && oversized.size() == limit + 2, + "failed restoration cannot offer success or Undo"); + } + ShortcutCapture capture; + ShortcutPopupKeys popupKeys; + Check(popupKeys.Tick(false, true, true, false) == ShortcutPopupKey::None, "Enter during recording cannot confirm"); + Check(popupKeys.Tick(true, true, true, false) == ShortcutPopupKey::None, + "captured Enter cannot confirm itself when the preview becomes ready"); + Check(popupKeys.Tick(true, false, false, false) == ShortcutPopupKey::None + && popupKeys.Tick(true, true, true, false) == ShortcutPopupKey::Confirm, + "after release a fresh Enter confirms a ready preview"); + Check(popupKeys.Tick(true, true, false, false) == ShortcutPopupKey::None, "holding Enter cannot repeat confirmation"); + popupKeys.Tick(true, false, false, false); + Check(popupKeys.Tick(true, true, true, true) == ShortcutPopupKey::Cancel, + "Escape takes priority over simultaneous confirmation"); + Check(popupKeys.Tick(false, false, false, true) == ShortcutPopupKey::Cancel, + "Escape cancels even when confirmation is unavailable"); + popupKeys.Tick(true, false, false, false); + popupKeys.Tick(false, false, false, false); + Check(popupKeys.Tick(true, true, true, false) == ShortcutPopupKey::None, + "Record again resets confirmation readiness"); + ShortcutCapture::Keys held{}, down{}; + auto modifier = [](KeyCode key) { return key == KeyCode::LeftControl; }; + auto tick = [&] { return capture.Tick(held, down, true, modifier); }; + capture.Begin(); + held[(int)KeyCode::Mouse0] = true; + down[(int)KeyCode::Mouse0] = true; + Check(!tick() && !capture.listening(), "opening click cannot be captured"); + held = {}; + down = {}; + tick(); + Check(capture.listening(), "all opening keys must release"); + held[(int)KeyCode::LeftControl] = true; + down[(int)KeyCode::LeftControl] = true; + Check(!tick() && capture.listening(), "modifier alone is not a binding"); + down = {}; + held[(int)KeyCode::I] = true; + down[(int)KeyCode::I] = true; + Check(tick() == KeyCode::I && capture.active(), "record primary but retain input ownership"); + down = {}; + tick(); + Check(capture.active(), "held captured chord cannot leak to gameplay"); + held[(int)KeyCode::I] = false; + tick(); + Check(capture.active(), "modifier release also required"); + held = {}; + tick(); + Check(!capture.active(), "release ends ownership"); + capture.Begin(); + tick(); + held[(int)KeyCode::Escape] = true; + down[(int)KeyCode::Escape] = true; + Check(!tick() && capture.active(), "escape cancels instead of binding"); + down = {}; + held = {}; + tick(); + Check(!capture.active(), "escape release ends cancellation drain"); + capture.Begin(); + tick(); + held[(int)KeyCode::I] = true; + down[(int)KeyCode::I] = true; + Check(!capture.Tick(held, down, false, modifier) && capture.active(), "focus loss cancels and drains"); + held = {}; + down = {}; + capture.Tick(held, down, false, modifier); + Check(capture.active(), "an empty unfocused input sample must not end ownership"); + held[(int)KeyCode::I] = true; + tick(); + Check(capture.active(), "refocusing with the chord still held remains blocked"); + held = {}; + tick(); + Check(!capture.active(), "a focused release ends the cancellation drain"); + // The popup can close while capture is idle (preview/confirmation). Its + // closing input must be drained just like a captured chord. + capture.Begin(); + capture.Cancel(); + held[(int)KeyCode::Return] = true; + down = held; + Check(!tick() && capture.active(), "closing preview retains the submit key"); + held = {}; + down = {}; + Check(!capture.Tick(held, down, false, modifier) && capture.active(), + "closing preview cannot release ownership on an unfocused sample"); + tick(); + Check(!capture.active(), "closing preview releases ownership after focused key release"); + capture.Begin(); + tick(); + held[(int)KeyCode::I] = held[(int)KeyCode::G] = true; + down = held; + Check(!tick() && !capture.listening(), "two simultaneous primary keys are ambiguous"); + std::cout << "Shortcut draft and capture fixtures passed\n"; +} diff --git a/tests/shortcut_hint_cache.cc b/tests/shortcut_hint_cache.cc index fc23adb80..1b82ce68a 100644 --- a/tests/shortcut_hint_cache.cc +++ b/tests/shortcut_hint_cache.cc @@ -108,6 +108,28 @@ int main() MapKey::AddMappedKey(toggle, MapKey::Parse("CTRL-DOWN")); Check(MapKey::IsDown(toggle) && claimed == KeyCode::DownArrow, "Other chord lost directional ownership"); held.fill(false); + // Editing metadata includes NONE actions; replacing bindings publishes one + // complete list and refreshes its first hint without changing dispatch rules. + MapKey::RegisterAction(galaxy, "show_galaxy", "F8"); + MapKey::RegisterAction(GameFunction::ShowResearch, "show_research", "NONE"); + Check(MapKey::Definition(galaxy).key == "show_galaxy" + && MapKey::Definition(GameFunction::ShowResearch).defaultBinding == "NONE", + "Editor registration lost a canonical key or unbound default"); + Check(MapKey::ReplaceBindings(galaxy, {MapKey::Parse("F8"), MapKey::Parse("CTRL-G")}) + && MapKey::Bindings(galaxy).size() == 2 && MapKey::GetShortcutHint(galaxy) == "F8", + "Replacing bindings did not atomically refresh the first hint"); + Check(!MapKey::ReplaceBindings(galaxy, {MapKey::Parse("INVALID")}) && MapKey::GetShortcuts(galaxy) == "F8 | CTRL-G", + "Invalid replacement damaged existing bindings"); + Check(MapKey::SameBinding(MapKey::Parse("CTRL-SHIFT-G"), MapKey::Parse("SHIFT-CTRL-G")) + && !MapKey::SameBinding(MapKey::Parse("CTRL-G"), MapKey::Parse("SHIFT-G")), + "Binding identity must ignore modifier order but retain modifier requirements"); + Check(MapKey::MayOverlap(MapKey::Parse("CTRL-G"), MapKey::Parse("SHIFT-G")) + && MapKey::MayOverlap(MapKey::Parse("G"), MapKey::Parse("G")) + && !MapKey::MayOverlap(MapKey::Parse("G"), MapKey::Parse("SHIFT-G")) + && !MapKey::MayOverlap(MapKey::Parse("CTRL-F8"), MapKey::Parse("CTRL-G")), + "Overlap advice differs from the existing physical minimum-modifier matcher"); + Check(MapKey::ReplaceBindings(galaxy, {}) && !MapKey::HasBinding(galaxy) && MapKey::GetShortcutHint(galaxy).empty(), + "Removing all alternatives did not unbind the action"); std::cout << "Shortcut hint and movement binding tests passed\n"; } diff --git a/tests/slider_setting_test.cc b/tests/slider_setting_test.cc new file mode 100644 index 000000000..32550176b --- /dev/null +++ b/tests/slider_setting_test.cc @@ -0,0 +1,116 @@ +#include "settings/native_boolean_callback.h" +#include "settings/native_view_state.h" +#include "settings/page_catalog.h" +#include +#include +#include + +using namespace mod_settings; +float stored = 0; +float GetFloat(Il2CppObject*, const MethodInfo*) +{ return stored; } +void SetFloat(Il2CppObject*, float value, const MethodInfo*) +{ stored = value; } +int main() +{ + float value = 0.33333f; + bool enabled = true, available = true; + int writes = 0; + SliderSetting setting( + {"threshold", "Threshold", + [&] { return available ? ValueReadResult::Known(value, 1) : ValueReadResult{}; }, + [&](float desired, std::uint64_t) { + ++writes; + value = desired; + return ApplyResult::Applied; + }}, + 0, 1, 0.01f, [&] { return enabled; }, SliderLabel::Percentage, 2, "Enable test feature"); + NativeViewState view(setting), stale(setting); + view.Bind(); + stale.Bind(); + assert(view.known() && view.number() == 0.33333f); // Reading does not quantize player-authored values. + assert(view.displayNumber() == 0.33f && value == 0.33333f && writes == 0); + assert(view.Request(0.604f) == Outcome::AppliedVerified && std::abs(value - 0.6f) < 0.000001f); + assert(stale.Request(0.8f) == Outcome::Conflict && writes == 1); + assert(view.Request(std::numeric_limits::quiet_NaN()) == Outcome::Rejected); + assert(view.Request(std::numeric_limits::infinity()) == Outcome::Rejected); + assert(view.Request(-0.01f) == Outcome::Rejected && view.Request(1.01f) == Outcome::Rejected); + enabled = false; + assert(view.known() && !view.enabled() && view.Request(0.9f) == Outcome::Rejected && writes == 1); + assert(view.disabledReason() == "Enable test feature"); + enabled = true; + { + NativeViewState::RenderScope render(view); + assert(view.Request(0.9f) == Outcome::Suppressed && writes == 1); + } + assert(view.Request(1.0f) == Outcome::AppliedVerified && value == 1.0f); + assert(view.Request(0.0f) == Outcome::AppliedVerified && value == 0.0f); + available = false; + view.Bind(); + assert(!view.known() && !view.enabled() && view.Request(0.8f) == Outcome::Rejected); + available = true; + value = std::numeric_limits::quiet_NaN(); + view.Bind(); + assert(!view.known()); + assert(view.unavailableReason() == "Invalid value; edit TOML"); + value = 0.5f; + view.Bind(); + view.Unbind(); + assert(view.Request(0.7f) == Outcome::Rejected); + PageCatalog catalog("root", "Mod Settings"); + assert(catalog.AddPage("labels", "Fleet Labels", "root") == Registration::Added); + assert(catalog.AddSlider("labels", setting) == Registration::Added); + assert(catalog.AddSlider("labels", setting) == Registration::Duplicate); + assert(catalog.Build().size() == 2); + assert(catalog.AddSlider("labels", setting) == Registration::Frozen); + + // Native drag labels can arrive after a change has already snapped/read back. + // Display that snapshot, and keep display precision independent of the step. + value = 382.1429f; + SliderSetting speed( + {"speed", "Speed", [&] { return ValueReadResult::Known(value, 1); }, + [&](float desired, std::uint64_t) { + value = desired; + return ApplyResult::Applied; + }}, + 0, 1000, 25, [] { return true; }, SliderLabel::Value, 0); + NativeViewState speedView(speed); + speedView.Bind(); + assert(speedView.disabledReason().empty()); // Generic sliders have no feature-specific instruction. + value = 2000.0f; + speedView.Bind(); + assert(!speedView.known() && speedView.unavailableReason() == "Outside slider range; use Edit"); + speedView.Bind(); // Reopening cannot repair a value outside this editor's range. + assert(value == 2000.0f && speedView.unavailableReason() == "Outside slider range; use Edit"); + value = 382.1429f; + speedView.Bind(); + assert(speedView.displayNumber() == 382 && value == 382.1429f); + assert(speedView.Request(382.1429f) == Outcome::AppliedVerified); + assert(speedView.number() == 375 && speedView.displayNumber() == 375); + assert(setting.DisplayValue(0.72575f) == 0.73f); + assert(setting.DisplayValue(0.0f) == 0 && setting.DisplayValue(0.99f) == 0.99f); + + Il2CppType single{}, nothing{}, integer{}; + single.type = IL2CPP_TYPE_R4; + nothing.type = IL2CPP_TYPE_VOID; + integer.type = IL2CPP_TYPE_I4; + const Il2CppType* parameters[]{&single}; + MethodInfo getter{}, setter{}; + getter.return_type = &single; + setter.return_type = ¬hing; + setter.parameters = parameters; + setter.parameters_count = 1; + NativeCallback get; + NativeCallback set; + assert(get.Initialize(&getter, GetFloat) && set.Initialize(&setter, SetFloat)); + float input = 0.67f, output = 0; + void* args[]{&input}; + set.method()->invoker_method(nullptr, set.method(), nullptr, args, nullptr); + get.method()->invoker_method(nullptr, get.method(), nullptr, nullptr, &output); + assert(output == input); + NativeCallback wrong; + getter.return_type = &integer; + assert(!wrong.Initialize(&getter, GetFloat)); + std::cout + << "PASS slider: range, finite values, dependency, readback, stale views, render guard and Single callbacks\n"; +} diff --git a/tests/test_settings_coverage.py b/tests/test_settings_coverage.py new file mode 100644 index 000000000..62e808952 --- /dev/null +++ b/tests/test_settings_coverage.py @@ -0,0 +1,123 @@ +"""Local maintenance gate for the complete in-game settings contribution. + +Run with: python -m unittest discover -s tests -p test_settings_coverage.py +No game, credentials, network, or installed configuration is read. +""" + +import json +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def source(name): + return (ROOT / name).read_text(encoding="utf-8") + + +def sync_options(): + return set(re.findall( + r'SyncConfig::Option\{.*?"[^\"]+",\s*"([^\"]+)"', + source("mods/src/config.h"), re.S)) + + +def loader_keys(): + config = source("mods/src/config.cc") + load = config[config.index("void Config::Load()") :] + keys = {".".join(pair) for pair in re.findall( + r'get_config_or_default(?:_with_alias)?(?:<[^>]+>)?\s*' + r'\(\s*config\s*,\s*parsed\s*,\s*"([^\"]+)"\s*,\s*"([^\"]+)"', load)} + keys |= {"graphics." + key for key in re.findall( + r'get_fleet_label_(?:detail|zoom_threshold)\(\s*config,\s*parsed,\s*"([^\"]+)"', load)} + keys |= {"ui." + key for key in re.findall( + r'get_mission_hud_visibility\(\s*config,\s*parsed,\s*"([^\"]+)"', load)} + keys |= {"ui." + key for key in re.findall( + r'read_instant_warp_filter\(\s*config,\s*parsed,\s*"([^\"]+)"', load)} + if "read_daily_bulk_claim_factions(config, parsed" in load: + keys.add("ui.daily_bulk_claim_factions") + if "get_auto_confirm_instant_warp(config, parsed" in load: + keys.add("ui.auto_confirm_instant_warp") + # The audio table is shared by startup loading and notification dispatch. + keys |= {"audio." + key for key in set(re.findall(r'"(alert_[a-z_]+)"', config))} + keys |= {"sync." + key for key in sync_options()} + return keys + + +class SettingsCoverage(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.inventory = json.loads(source("docs/settings-coverage.json")) + cls.rows = {row["key"]: row for row in cls.inventory["settings"]} + cls.catalog = source("mods/src/settings/upstream_settings.cc") + + def test_every_loader_key_has_an_explicit_treatment(self): + self.assertEqual(len(self.rows), len(self.inventory["settings"]), "duplicate canonical key") + actual = loader_keys() + self.assertFalse(actual - set(self.rows), + f"new config options need coverage: {sorted(actual - set(self.rows))}") + self.assertFalse(set(self.rows) - actual, + f"stale inventory entries: {sorted(set(self.rows) - actual)}") + for row in self.rows.values(): + self.assertIn(row["treatment"], {"in_game", "toml_only"}) + self.assertIn(row["application"], {"live", "reopen_view", "restart"}) + if row["treatment"] == "toml_only": + self.assertTrue(self.inventory["toml_only_policies"][row["policy"]]) + + def test_every_in_game_key_has_a_definition(self): + definitions = self.catalog + source("mods/src/settings/warp_mode.cc") + source("mods/src/settings/forbidden_tech.cc") + literals = set(re.findall(r'"([a-z][a-z_0-9]+)"', definitions)) + literals |= {"hud_" + name for name in ("q_trials", "field_training", "outposts", "missions") + if f'"{name}"' in self.catalog} + for row in self.rows.values(): + if row["treatment"] != "in_game": + continue + with self.subTest(key=row["key"]): + self.assertIn(row["key"].split(".", 1)[1], literals) + self.assertTrue(row["control"]) + self.assertTrue(row["page"]) + self.assertIn("PersistedDefinition(std::move(definition)", self.catalog) + self.assertNotIn("runtime_config::SaveSetting", self.catalog, + "field callbacks must use their definition's serializer") + self.assertRegex(self.catalog, r"action->metadata\s*=\s*&setting->metadata\(\)") + self.assertIn("action->save_status", self.catalog) + + def test_all_registered_shortcuts_are_included(self): + keys = re.findall( + r'parse_config_shortcut(?:_aliases)?\(config,\s*parsed,\s*"([^\"]+)"', + source("mods/src/config.cc")) + expected = {"shortcuts." + key for key in keys} + self.assertEqual(len(keys), len(expected), "duplicate canonical shortcut registration") + self.assertEqual(expected, set(self.inventory["shortcuts"])) + self.assertIn("DiscoverShortcuts(", source("mods/src/settings/shortcut_settings.cc")) + self.assertEqual(self.inventory["shortcuts_policy"]["application"], "live") + + def test_dynamic_target_fields_follow_secret_safe_policy(self): + expected = {"sync.targets.." + key + for key in {"url", "token", "proxy", "verify_ssl"} | sync_options()} + self.assertEqual(expected, set(self.inventory["dynamic_settings"])) + self.assertEqual(self.inventory["dynamic_settings_policy"]["treatment"], "toml_only") + self.assertNotRegex(self.catalog, r'\.token\b|\["token"\]') + self.assertIn("Credentials are never displayed", self.catalog) + + def test_non_ui_actions_share_serializers_with_lazy_initialization(self): + begin = self.catalog.index("bool PersistCurrentSetting(") + end = self.catalog.index("void RegisterUpstreamSettings(", begin) + saver = self.catalog[begin:end] + self.assertIn("RegisterUpstreamSettings(ModPages())", saver) + self.assertIn("persist = result.persist", self.catalog) + self.assertIn("persist(read())", self.catalog) + + def test_only_upstream_feature_dependencies_are_included(self): + for forbidden in ("claim_trace", "galaxy_controls", "ship_tech_indicators", "opc_indicators", + "incoming_player_attack", "audio_file_picker", "notification_desktop", + "keyboard_layout", "ship_shortcut_badges"): + self.assertNotIn(f'#include "{forbidden}', self.catalog) + self.assertNotIn(f'#include "patches/{forbidden}', self.catalog) + for key in ("control.enable_experimental", "graphics.system_pan_momentum"): + self.assertEqual(self.rows[key]["policy"], "legacy_experimental") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/toml_editor_test.cc b/tests/toml_editor_test.cc new file mode 100644 index 000000000..c332cbfd8 --- /dev/null +++ b/tests/toml_editor_test.cc @@ -0,0 +1,121 @@ +#include "config_save.h" +#include "toml_editor.h" +#include +#include +#include +#include + +using namespace config_edit; +static Request Mode(std::optional expected, std::string desired) +{ return {"ui", "auto_confirm_instant_warp", std::move(expected), std::move(desired)}; } +int main(int argc, char** argv) +{ + assert(argc == 2); + TomlEditor editor; + const auto request = Mode(Value{std::string("none")}, "warp"); + for (const std::string original : + {"# header\n[ui] # section\nauto_confirm_instant_warp = 'none' # comment\nother = 9\n", + "\xef\xbb\xbf# BOM\r\n[ui]\r\nauto_confirm_instant_warp = 'none' # comment\r\n", + "ui = { other = '\xc3\xa9', auto_confirm_instant_warp = 'none' } # tail\n", + "ui.auto_confirm_instant_warp = '''none'''\n[elsewhere]\nx = nan\n", + "[\"ui\"]\n\"auto_confirm_instant_warp\" = \"\"\"none\"\"\""}) { + auto edit = editor.Prepare(original, request); + assert(edit.outcome == Outcome::Prepared); + const auto parsed = toml::parse(edit.text); + assert(parsed["ui"]["auto_confirm_instant_warp"].value() == "warp"); + // Only the value token changes; comments, surrounding bytes and line endings remain. + const auto old = original.find("'''none'''") != std::string::npos ? "'''none'''" + : original.find("\"\"\"none\"\"\"") != std::string::npos ? "\"\"\"none\"\"\"" + : "'none'"; + auto expected = original; + expected.replace(expected.find(old), std::string(old).size(), "\"warp\""); + assert(edit.text == expected); + } + for (const std::string original : + {"", "# comments only", "[ui]", "[ui]\nother = 4\n[other]\nx=3\n", "ui = {} # inline\n", "ui = {other = 4}\n", + "ui.other = 4\n", "[ui.child]\nx = 4\n"}) { + const auto edit = editor.Prepare(original, Mode(std::nullopt, "jump")); + assert(edit.outcome == Outcome::Prepared); + assert(toml::parse(edit.text)["ui"]["auto_confirm_instant_warp"].value() == "jump"); + } + const std::string special = "quotes \"'\\\n[ui]\nauto_confirm_instant_warp = 'jump'\n\xc3\xa9"; + for (const std::string malformed : {std::string("\xff", 1), std::string("\xc0\xaf", 2), std::string("\xe2\x82", 2), + std::string("\xed\xa0\x80", 3), std::string("\xf4\x90\x80\x80", 4)}) { + const auto refused = editor.Prepare("[ui]\nauto_confirm_instant_warp='none'\n", Mode(request.expected, malformed)); + assert(refused.outcome == Outcome::Unsupported && refused.text.empty()); + } + const auto escaped = editor.Prepare("ui = {auto_confirm_instant_warp='none'}", Mode(request.expected, special)); + assert(escaped.outcome == Outcome::Prepared); + assert(toml::parse(escaped.text)["ui"]["auto_confirm_instant_warp"].value() == special); + assert(editor.Prepare("[ui]\nauto_confirm_instant_warp='jump'", request).outcome == Outcome::Conflict); + assert(editor.Prepare("[ui]\nauto_confirm_instant_warp='warp'", request).outcome == Outcome::AlreadySaved); + assert(editor.Prepare("ui = [", request).outcome == Outcome::InvalidDocument); + assert(editor.Prepare("ui = 3", request).outcome == Outcome::Unsupported); + assert(editor.Prepare("[ui]\nother=true", request).outcome == Outcome::Conflict); + // Decoded key identities may contain dots, Unicode and combining codepoints. + // Columns are parser coordinates, not UTF-8 byte counts or display widths. + const std::string unicode_section = "ui.\xc3\xa9"; + const std::string unicode_key = "e\xcc\x81.mode"; + const std::string unicode_document = + "[\"" + unicode_section + "\"]\r\n\"" + unicode_key + "\" = 'none' # untouched\r\n"; + const auto unicode_edit = + editor.Prepare(unicode_document, {unicode_section, unicode_key, Value{std::string("none")}, std::string("jump")}); + assert(unicode_edit.outcome == Outcome::Prepared); + auto unicode_expected = unicode_document; + unicode_expected.replace(unicode_expected.find("'none'"), 6, "\"jump\""); + assert(unicode_edit.text == unicode_expected); + const std::string combining = "ui = { other = 'e\xcc\x81', auto_confirm_instant_warp = 'none' }\n"; + auto combining_expected = combining; + combining_expected.replace(combining_expected.find("'none'"), 6, "\"warp\""); + assert(editor.Prepare(combining, request).text == combining_expected); + const Request boolean{"ui", "enabled", Value{false}, true}; + const auto numeric = + editor.Prepare("[graphics]\nthreshold = 0.5 # keep\n", {"graphics", "threshold", Value{0.5}, 0.75}); + assert(numeric.outcome == Outcome::Prepared && numeric.text == "[graphics]\nthreshold = 0.75 # keep\n"); + assert(editor.Prepare("[graphics]\nthreshold = 0.6\n", {"graphics", "threshold", Value{0.5}, 0.75}).outcome + == Outcome::Conflict); + const auto integer = + editor.Prepare("[graphics]\nthreshold = 0\n", {"graphics", "threshold", Value{std::int64_t{0}}, 0.5}); + assert(integer.outcome == Outcome::Prepared + && toml::parse(integer.text)["graphics"]["threshold"].value() == 0.5); + for (const auto desired : {std::numeric_limits::min(), std::numeric_limits::max()}) { + const auto whole = + editor.Prepare("[graphics]\ncount = 0 # keep\n", {"graphics", "count", Value{std::int64_t{0}}, desired}); + assert(whole.outcome == Outcome::Prepared && whole.text.ends_with(" # keep\n")); + assert(toml::parse(whole.text)["graphics"]["count"].value() == desired); + } + for (const auto desired : {std::numeric_limits::infinity(), -std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + const auto invalid = + editor.Prepare("[graphics]\nthreshold = 0.5 # keep\n", {"graphics", "threshold", Value{0.5}, desired}); + assert(invalid.outcome == Outcome::Unsupported && invalid.text.empty()); + } + assert(editor.Prepare("[ui]\nenabled = false # keep\n", boolean).text == "[ui]\nenabled = true # keep\n"); + assert(editor.Prepare("[ui]\nenabled = true", boolean).outcome == Outcome::AlreadySaved); + assert(editor.Prepare("[ui]\nenabled = 'false'", boolean).outcome == Outcome::Conflict); + const std::string escaped_key = "a.\"b\\c"; + for (const std::string document : {"[ui]\n", "ui = {}\n"}) { + const auto inserted = editor.Prepare(document, {"ui", escaped_key, std::nullopt, true}); + assert(inserted.outcome == Outcome::Prepared); + assert(toml::parse(inserted.text)["ui"][escaped_key].value() == true); + } + + const std::filesystem::path root(argv[1]); + std::filesystem::create_directories(root); + const auto path = root / "settings.toml"; + const std::string initial = "[ui]\nauto_confirm_instant_warp='none'\nother=true # preserve\n"; + ReplaceConfigText(path, initial); + const auto external = initial + "# external comment\n"; + ReplaceConfigText(path, external); + assert(editor.Save(path, request) == Outcome::Saved); + assert(ReadConfigText(path).ends_with("# external comment\n")); + ReplaceConfigText(path, "[ui]\nauto_confirm_instant_warp='jump'\n"); + assert(editor.Save(path, request) == Outcome::Conflict); + assert(!ReplaceConfigText(path, initial, external)); + assert(toml::parse(ReadConfigText(path))["ui"]["auto_confirm_instant_warp"].value() == "jump"); + const auto absent = root / "absent.toml"; + assert(!std::filesystem::exists(absent)); + assert(editor.Save(absent, request) == Outcome::IoError); + assert(!std::filesystem::exists(absent)); + std::cout << "TOML editor preservation/conflict fixtures passed\n"; +} diff --git a/tests/upstream_settings_fixture/file.h b/tests/upstream_settings_fixture/file.h new file mode 100644 index 000000000..702be9ee4 --- /dev/null +++ b/tests/upstream_settings_fixture/file.h @@ -0,0 +1,16 @@ +#pragma once +#include +#include + +// Test-only path boundary. The production catalog still compiles unchanged; +// this fixture never resolves a game installation or an installed config. +class File +{ +public: + static const char* Config(); +#if _WIN32 + static std::string_view MakePath(std::string_view, bool = false, bool = false); +#else + static std::u8string MakePath(std::string_view, bool = false, bool = false); +#endif +}; diff --git a/tests/upstream_settings_fixture/spdlog/spdlog.h b/tests/upstream_settings_fixture/spdlog/spdlog.h new file mode 100644 index 000000000..91aabba11 --- /dev/null +++ b/tests/upstream_settings_fixture/spdlog/spdlog.h @@ -0,0 +1,6 @@ +#pragma once +// Logging is an external boundary, not part of the catalog under test. +namespace spdlog +{ +template void info(const char*, Args&&...) {} +} // namespace spdlog diff --git a/tests/upstream_settings_test.cc b/tests/upstream_settings_test.cc new file mode 100644 index 000000000..c016a7085 --- /dev/null +++ b/tests/upstream_settings_test.cc @@ -0,0 +1,763 @@ +// Compile the actual catalog and shared warp/FT models. Only process/config, +// path, text-modal, logging, parser, and save boundaries are controlled here. +#include "config.h" +#include "defaultconfig.h" +#include "file.h" +#include "patches/runtime_config.h" +#include "settings/mod_pages.h" +#include "settings/settings_search.h" +#include "settings/text_entry.h" +#include "settings/upstream_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fixture +{ +using Json = nlohmann::json; +using Value = config_edit::Value; +struct Write { + std::string key; + Value value; +}; +std::vector writes; +std::set registered; +std::optional modal; +bool labelsAvailable = true; +int labelRefreshes = 0, hudRefreshes = 0, shipRefreshes = 0, parseCalls = 0; +int queueTrackingResets = 0; +float previousShipScale = 0, nextShipScale = 0; + +void Require(bool condition, const std::string& message) +{ + if (!condition) + throw std::runtime_error(message); +} + +template Value V(const T& value) +{ + if constexpr (std::is_same_v || std::is_same_v) + return value; + else if constexpr (std::is_integral_v) + return static_cast(value); + else + return static_cast(value); +} +Json J(const Value& value) +{ + return std::visit([](const auto& part) { return Json(part); }, value); +} + +std::string Join(const std::vector& names) +{ + std::string result; + for (const auto& name : names) { + if (!result.empty()) + result += ", "; + result += name; + } + return result; +} +std::string Trim(std::string_view value) +{ + const auto first = value.find_first_not_of(" \t\r\n"); + return first == value.npos ? std::string{} + : std::string(value.substr(first, value.find_last_not_of(" \t\r\n") - first + 1)); +} +std::string Lower(std::string value) +{ + for (auto& c : value) + c = static_cast(std::tolower(static_cast(c))); + return value; +} + +// Independent native-field mapping, taken from Config::Load rather than the +// catalog. Changing the wrong Config member must fail the all-model exercise. +std::map> NativeReads() +{ + auto& cfg = Config::Get(); + return { + {"audio.alert_armada_battle_lost", + [&cfg] { return V(std::string(notification_sound_name(cfg.alert_armada_battle_lost))); }}, + {"audio.alert_armada_battle_won", + [&cfg] { return V(std::string(notification_sound_name(cfg.alert_armada_battle_won))); }}, + {"audio.alert_armada_created", + [&cfg] { return V(std::string(notification_sound_name(cfg.alert_armada_created))); }}, + {"audio.alert_defeat", [&cfg] { return V(std::string(notification_sound_name(cfg.alert_defeat))); }}, + {"audio.alert_victory", [&cfg] { return V(std::string(notification_sound_name(cfg.alert_victory))); }}, + {"buffs.use_out_of_dock_power", [&cfg] { return V(cfg.use_out_of_dock_power); }}, + {"control.hotkeys_enabled", [&cfg] { return V(cfg.hotkeys_enabled); }}, + {"control.hotkeys_extended", [&cfg] { return V(cfg.hotkeys_extended); }}, + {"control.kirshara_fixes", [&cfg] { return V(cfg.kirshara_fixes); }}, + {"control.primary_requires_visible_target", [&cfg] { return V(cfg.primary_requires_visible_target); }}, + {"control.queue_enabled", [&cfg] { return V(cfg.queue_enabled); }}, + {"control.select_timer", [&cfg] { return V(cfg.select_timer); }}, + {"control.use_scopely_hotkeys", [&cfg] { return V(cfg.use_scopely_hotkeys); }}, + {"graphics.allow_cursor", [&cfg] { return V(cfg.allow_cursor); }}, +#if _WIN32 + {"graphics.borderless_fullscreen", [&cfg] { return V(cfg.borderless_fullscreen); }}, +#endif + {"graphics.default_system_zoom", [&cfg] { return V(cfg.default_system_zoom); }}, + {"graphics.fr_scale", [&cfg] { return V(cfg.fr_scale); }}, +#if _WIN32 + {"graphics.free_resize", [&cfg] { return V(cfg.free_resize); }}, +#endif + {"graphics.galactic_anomaly_timer", [&cfg] { return V(cfg.galactic_anomaly_timer); }}, + {"graphics.haven_zoom", [&cfg] { return V(cfg.haven_zoom); }}, + {"graphics.keyboard_zoom_speed", [&cfg] { return V(cfg.keyboard_zoom_speed); }}, + {"graphics.loader_enabled", [&cfg] { return V(cfg.loader_enabled); }}, + {"graphics.loader_image", [&cfg] { return V(cfg.loader_image); }}, + {"graphics.loader_logo_scale", [&cfg] { return V(cfg.loader_logo_scale); }}, + {"graphics.loader_tip_enabled", [&cfg] { return V(cfg.loader_tip_enabled); }}, + {"graphics.loader_transition", [&cfg] { return V(cfg.loader_transition); }}, + {"graphics.loader_transition_black", [&cfg] { return V(cfg.loader_transition_black); }}, + {"graphics.system_pan_momentum_falloff", [&cfg] { return V(cfg.system_pan_momentum_falloff); }}, + {"graphics.system_zoom_preset_1", [&cfg] { return V(cfg.system_zoom_preset_1); }}, + {"graphics.system_zoom_preset_2", [&cfg] { return V(cfg.system_zoom_preset_2); }}, + {"graphics.system_zoom_preset_3", [&cfg] { return V(cfg.system_zoom_preset_3); }}, + {"graphics.system_zoom_preset_4", [&cfg] { return V(cfg.system_zoom_preset_4); }}, + {"graphics.system_zoom_preset_5", [&cfg] { return V(cfg.system_zoom_preset_5); }}, + {"graphics.transition_time", [&cfg] { return V(cfg.transition_time); }}, + {"graphics.ui_scale", [&cfg] { return V(cfg.ui_scale); }}, + {"graphics.ui_scale_ship", [&cfg] { return V(cfg.ui_scale_ship); }}, + {"graphics.ui_scale_step", [&cfg] { return V(cfg.ui_scale_step); }}, + {"graphics.ui_scale_viewer", [&cfg] { return V(cfg.ui_scale_viewer); }}, + {"graphics.use_presets_as_default", [&cfg] { return V(cfg.use_presets_as_default); }}, + {"graphics.zoom", [&cfg] { return V(cfg.zoom); }}, + {"graphics.zoom_label_non_player_detail", + [&cfg] { + return V(std::string(std::array{"native", "expanded", "compact", "threshold"}.at( + static_cast(cfg.zoom_label_non_player.detail)))); + }}, + {"graphics.zoom_label_non_player_threshold", [&cfg] { return V(cfg.zoom_label_non_player.zoom_threshold); }}, + {"graphics.zoom_label_player_detail", + [&cfg] { + return V(std::string(std::array{"native", "expanded", "compact", "threshold"}.at( + static_cast(cfg.zoom_label_player.detail)))); + }}, + {"graphics.zoom_label_player_threshold", [&cfg] { return V(cfg.zoom_label_player.zoom_threshold); }}, + {"ui.always_skip_reveal_sequence", [&cfg] { return V(cfg.always_skip_reveal_sequence); }}, + {"ui.arrow_keys_to_select_ship", [&cfg] { return V(cfg.arrow_keys_to_select_ship); }}, + {"ui.auto_confirm_ft_upgrade", [&cfg] { return V(cfg.auto_confirm_ft_upgrade); }}, + {"ui.auto_confirm_instant_warp", + [&cfg] { + return V(std::string(std::array{"none", "warp", "jump"}.at(static_cast(cfg.auto_confirm_instant_warp)))); + }}, + {"ui.auto_confirm_reveal_ability", [&cfg] { return V(cfg.auto_confirm_reveal_ability); }}, + {"ui.auto_open_bulk_claim_flyout", [&cfg] { return V(cfg.auto_open_bulk_claim_flyout); }}, + {"ui.cargo_significant_decimals", [&cfg] { return V(cfg.cargo_significant_decimals); }}, + {"ui.daily_bulk_claim_factions", [&cfg] { return V(Join(cfg.daily_bulk_claim_factions)); }}, + {"ui.daily_bulk_claim_toggle_default_on", [&cfg] { return V(cfg.daily_bulk_claim_toggle_default_on); }}, + {"ui.disable_escape_exit", [&cfg] { return V(cfg.disable_escape_exit); }}, + {"ui.disable_escape_exit_timer", [&cfg] { return V(cfg.disable_escape_exit_timer); }}, + {"ui.disable_exchange_all", [&cfg] { return V(cfg.disable_exchange_all); }}, + {"ui.disable_galaxy_chat", [&cfg] { return V(cfg.disable_galaxy_chat); }}, + {"ui.disable_move_keys", [&cfg] { return V(cfg.disable_move_keys); }}, + {"ui.disable_preview_locate", [&cfg] { return V(cfg.disable_preview_locate); }}, + {"ui.disable_preview_recall", [&cfg] { return V(cfg.disable_preview_recall); }}, + {"ui.disable_toast_banners", [&cfg] { return V(cfg.disable_toast_banners); }}, + {"ui.disable_veil_chat", [&cfg] { return V(cfg.disable_veil_chat); }}, + {"ui.disabled_banner_types", [&cfg] { return V(FormatBannerFilter(cfg.disabled_banner_types)); }}, + {"ui.dock_syncmanagement", [&cfg] { return V(cfg.dock_syncmanagement); }}, + {"ui.double_click_to_assign_ship", [&cfg] { return V(cfg.double_click_to_assign_ship); }}, + {"ui.extend_chest_purchase_max", [&cfg] { return V(cfg.extend_chest_purchase_max); }}, + {"ui.extend_donation_max", [&cfg] { return V(cfg.extend_donation_max); }}, + {"ui.extend_donation_slider", [&cfg] { return V(cfg.extend_donation_slider); }}, + {"ui.focus_search", [&cfg] { return V(cfg.focus_search); }}, + {"ui.format_cargo_values", [&cfg] { return V(cfg.format_cargo_values); }}, + {"ui.hud_field_training", + [&cfg] { + return V(std::string(std::array{"auto", "always", "never"}.at( + static_cast(cfg.MissionHudButtonVisibility("field_training"))))); + }}, + {"ui.hud_missions", + [&cfg] { + return V(std::string( + std::array{"auto", "always", "never"}.at(static_cast(cfg.MissionHudButtonVisibility("missions"))))); + }}, + {"ui.hud_outposts", + [&cfg] { + return V(std::string( + std::array{"auto", "always", "never"}.at(static_cast(cfg.MissionHudButtonVisibility("outposts"))))); + }}, + {"ui.hud_q_trials", + [&cfg] { + return V(std::string( + std::array{"auto", "always", "never"}.at(static_cast(cfg.MissionHudButtonVisibility("q_trials"))))); + }}, + {"ui.instant_cargo_counter", [&cfg] { return V(cfg.instant_cargo_counter); }}, + {"ui.instant_warp_always_ask", + [&cfg] { return V(cfg.instant_warp_always_ask_all ? std::string("*") : Join(cfg.instant_warp_always_ask)); }}, + {"ui.instant_warp_auto_jump", + [&cfg] { return V(cfg.instant_warp_auto_jump_all ? std::string("*") : Join(cfg.instant_warp_auto_jump)); }}, + {"ui.instant_warp_auto_warp", + [&cfg] { return V(cfg.instant_warp_auto_warp_all ? std::string("*") : Join(cfg.instant_warp_auto_warp)); }}, + {"ui.notify_banner_types", [&cfg] { return V(FormatBannerFilter(cfg.notify_banner_types)); }}, + {"ui.officer_sort", [&cfg] { return V(cfg.officer_sort); }}, + {"ui.only_show_first_popup", [&cfg] { return V(cfg.only_show_first_popup); }}, + {"ui.pinned_ships", [&cfg] { return V(Join(cfg.pinned_ships)); }}, + {"ui.reverse_haven_history", [&cfg] { return V(cfg.reverse_haven_history); }}, + {"ui.show_armada_cargo", [&cfg] { return V(cfg.show_armada_cargo); }}, + {"ui.show_cargo_default", [&cfg] { return V(cfg.show_cargo_default); }}, + {"ui.show_hostile_cargo", [&cfg] { return V(cfg.show_hostile_cargo); }}, + {"ui.show_player_cargo", [&cfg] { return V(cfg.show_player_cargo); }}, + {"ui.show_player_title", [&cfg] { return V(cfg.show_player_title); }}, + {"ui.show_station_cargo", [&cfg] { return V(cfg.show_station_cargo); }}, + }; +} + +std::map ReadAll(const std::map>& reads) +{ + std::map result; + for (const auto& [key, read] : reads) + result.emplace(key, read()); + return result; +} +struct Model { + const mod_settings::SettingMetadata* metadata = nullptr; + std::function reset; + std::function available; + std::function change; + mod_settings::ActionSetting* exact = nullptr; +}; + +template Model Typed(mod_settings::ValueSetting& state, int choices = 0) +{ + Require(state.default_value().has_value(), "typed definition missing default: " + state.id()); + return {&state.metadata(), [&state] { state.Reset(state.Observe()); }, + [&state] { return state.Observe().state.known(); }, + [&state, choices](const Json&, const std::filesystem::path&) { + const auto before = state.Observe(); + Require(before.state.known(), "unavailable model: " + state.id()); + T desired; + if constexpr (std::is_same_v) + desired = !*before.state.value; + else if constexpr (std::is_same_v) + desired = (*before.state.value + 1) % choices; + else + desired = *before.state.value + static_cast(.01f); + const auto result = state.SetFromUser(desired, before).outcome; + Require(result == mod_settings::Outcome::AppliedVerified, "typed edit rejected: " + state.id()); + }}; +} + +Model Exact(mod_settings::ActionSetting& action) +{ + Require(action.default_label && action.reset && action.retry && action.prepare_reset && action.save_status, + "exact definition missing model operations: " + action.id()); + return {action.metadata, action.reset, [&action] { return action.Read(0).enabled; }, + [&action](const Json& row, const std::filesystem::path& image) { + modal.reset(); + action.invoke(0); + Require(modal.has_value(), "exact editor did not open: " + action.id()); + Require(!modal->help.empty(), "exact editor missing help: " + action.id()); + const std::string key = row.at("key"); + const std::string control = row.at("control"); + std::string value; + if (control == "number") { + std::ostringstream text; + const auto number = std::stod(modal->value); + const bool integer = std::holds_alternative(NativeReads().at(key)()); + text.precision(12); + text << number + (integer ? 1. : .01); + value = text.str(); + } else if (control == "path") + value = image.string(); + else if (key == "ui.daily_bulk_claim_factions") + value = "federation"; + else if (key == "ui.disabled_banner_types" || key == "ui.notify_banner_types") + value = "Victory"; + else + value = "enterprise"; + Require(modal->validate(value).empty(), "valid input rejected: " + key + " = " + value); + Require(modal->commit(value), "exact commit rejected: " + key); + modal.reset(); + }, + &action}; +} +} // namespace fixture + +// Config's sole fixture instance has static storage, so all scalar members are +// zero-initialized before this constructor. Used fields then receive the same +// named DefaultConfig constants as the startup loader. No Load/file API runs. +Config::Config() +{ + installUiScaleHooks = static_cast(DefaultConfig::Patches::uiscalehooks); + installZoomHooks = static_cast(DefaultConfig::Patches::zoomhooks); + installHavenZoomHooks = static_cast(DefaultConfig::Patches::havenzoomhooks); + installBuffFixHooks = static_cast(DefaultConfig::Patches::bufffixhooks); + installToastBannerHooks = static_cast(DefaultConfig::Patches::toastbannerhooks); + installPanHooks = static_cast(DefaultConfig::Patches::panhooks); + installHotkeyHooks = static_cast(DefaultConfig::Patches::hotkeyhooks); + installNativeSettings = static_cast(DefaultConfig::Patches::nativesettingshooks); + installRuntimeConfigHooks = + static_cast(DefaultConfig::Patches::runtimeconfighooks); + installFreeResizeHooks = static_cast(DefaultConfig::Patches::freeresizehooks); + installTempCrashFixes = static_cast(DefaultConfig::Patches::tempcrashfixes); + installTestPatches = static_cast(DefaultConfig::Patches::testpatches); + installMiscPatches = static_cast(DefaultConfig::Patches::miscpatches); + installMissionHudTweaksHooks = + static_cast(DefaultConfig::Patches::missionhudtweakshooks); + installChatPatches = static_cast(DefaultConfig::Patches::chatpatches); + installPlayerTitleDisplayHooks = + static_cast(DefaultConfig::Patches::chattitledisplayhooks); + installSyncPatches = static_cast(DefaultConfig::Patches::syncpatches); + installGameVersionHook = static_cast(DefaultConfig::Patches::game_version); + installObjectTracker = static_cast(DefaultConfig::Patches::objecttracker); + installLoadingScreenHooks = + static_cast(DefaultConfig::Patches::loadingscreenhooks); + installTransitionScreenHooks = + static_cast(DefaultConfig::Patches::transitionscreenhooks); + installGiftsBulkClaimHooks = + static_cast(DefaultConfig::Patches::giftsbulkclaimhooks); + installDailyFactionBulkClaimHooks = + static_cast(DefaultConfig::Patches::dailyfactionbulkclaimhooks); + installGalacticAnomalyTimerHooks = + static_cast(DefaultConfig::Patches::galacticanomalytimerhooks); + installLoadingTipHooks = static_cast(DefaultConfig::Patches::loadingtiphooks); + installDoubleClickAssignShipHooks = + static_cast(DefaultConfig::Patches::doubleclickassignshiphooks); + installForbiddenTechConfirmationHooks = + static_cast(DefaultConfig::Patches::forbiddentechconfirmhooks); + installRevealConfirmationHooks = + static_cast(DefaultConfig::Patches::revealconfirmhooks); + installQueueAddressGuardHooks = + static_cast(DefaultConfig::Patches::queueaddressguardhooks); + installActionQueueRecoveryHooks = + static_cast(DefaultConfig::Patches::actionqueuerecoveryhooks); + installThinQueueProtectionHooks = + static_cast(DefaultConfig::Patches::thinqueueprotectionhooks); + installArtifactExchangeHooks = + static_cast(DefaultConfig::Patches::artifactexchangehooks); + installAudioEventHooks = static_cast(DefaultConfig::Patches::audioeventhooks); + installInstantCargoCounterHooks = + static_cast(DefaultConfig::Patches::instantcargocounterhooks); + installCargoFormatHooks = static_cast(DefaultConfig::Patches::cargoformathooks); + installOfficerSortHooks = static_cast(DefaultConfig::Patches::officersorthooks); + installPinnedShipSortHooks = + static_cast(DefaultConfig::Patches::pinnedshiphooks); + installPinnedShipClickToggle = + static_cast(DefaultConfig::Patches::pinnedshipclicktoggle); + installHavenHistoryHooks = static_cast(DefaultConfig::Patches::havenhistoryhooks); + kirshara_fixes = static_cast(DefaultConfig::Control::kirshara_fixes); + queue_enabled = static_cast(DefaultConfig::Control::queue_enabled); + hotkeys_enabled = static_cast(DefaultConfig::Control::hotkeys_enabled); + hotkeys_extended = static_cast(DefaultConfig::Control::hotkeys_extended); + primary_requires_visible_target = + static_cast(DefaultConfig::Control::primary_requires_visible_target); + use_scopely_hotkeys = static_cast(DefaultConfig::Control::use_scopely_hotkeys); + select_timer = static_cast(DefaultConfig::Control::select_timer); + enable_experimental = static_cast(DefaultConfig::Control::enable_experimental); + ui_scale = static_cast(DefaultConfig::Graphics::ui_scale); + ui_scale_step = static_cast(DefaultConfig::Graphics::ui_scale_step); + ui_scale_ship = static_cast(DefaultConfig::Graphics::ui_scale_ship); + ui_scale_viewer = static_cast(DefaultConfig::Graphics::ui_scale_viewer); + zoom = static_cast(DefaultConfig::Graphics::zoom); + haven_zoom = static_cast(DefaultConfig::Graphics::haven_zoom); + fr_scale = static_cast(DefaultConfig::Graphics::fr_scale); + free_resize = static_cast(DefaultConfig::Graphics::free_resize); + allow_cursor = static_cast(DefaultConfig::Graphics::allow_cursor); + keyboard_zoom_speed = static_cast(DefaultConfig::Graphics::keyboard_zoom_speed); + system_pan_momentum = static_cast(DefaultConfig::Graphics::system_pan_momentum); + system_pan_momentum_falloff = + static_cast(DefaultConfig::Graphics::system_pan_momentum_falloff); + borderless_fullscreen = static_cast(DefaultConfig::Graphics::borderless_fullscreen); + transition_time = static_cast(DefaultConfig::Graphics::transition_time); + default_system_zoom = static_cast(DefaultConfig::Graphics::default_system_zoom); + system_zoom_preset_1 = static_cast(DefaultConfig::Graphics::system_zoom_preset_1); + system_zoom_preset_2 = static_cast(DefaultConfig::Graphics::system_zoom_preset_2); + system_zoom_preset_3 = static_cast(DefaultConfig::Graphics::system_zoom_preset_3); + system_zoom_preset_4 = static_cast(DefaultConfig::Graphics::system_zoom_preset_4); + system_zoom_preset_5 = static_cast(DefaultConfig::Graphics::system_zoom_preset_5); + use_presets_as_default = + static_cast(DefaultConfig::Graphics::use_presets_as_default); + use_out_of_dock_power = static_cast(DefaultConfig::Buffs::use_out_of_dock_power); + disable_escape_exit = static_cast(DefaultConfig::UI::disable_escape_exit); + disable_escape_exit_timer = + static_cast(DefaultConfig::UI::disable_escape_exit_timer); + disable_preview_locate = static_cast(DefaultConfig::UI::disable_preview_locate); + disable_preview_recall = static_cast(DefaultConfig::UI::disable_preview_recall); + only_show_first_popup = static_cast(DefaultConfig::UI::only_show_first_popup); + disable_move_keys = static_cast(DefaultConfig::UI::disable_move_keys); + disable_toast_banners = static_cast(DefaultConfig::UI::disable_toast_banners); + trace_audio_events = static_cast(DefaultConfig::Audio::trace_events); + auto_open_bulk_claim_flyout = + static_cast(DefaultConfig::UI::auto_open_bulk_claim_flyout); + daily_bulk_claim_toggle_default_on = + static_cast(DefaultConfig::UI::daily_bulk_claim_toggle_default_on); + double_click_to_assign_ship = + static_cast(DefaultConfig::UI::double_click_to_assign_ship); + focus_search = static_cast(DefaultConfig::UI::focus_search); + format_cargo_values = static_cast(DefaultConfig::UI::format_cargo_values); + officer_sort = static_cast(DefaultConfig::UI::officer_sort); + reverse_haven_history = static_cast(DefaultConfig::UI::reverse_haven_history); + dock_syncmanagement = static_cast(DefaultConfig::UI::dock_syncmanagement); + arrow_keys_to_select_ship = + static_cast(DefaultConfig::UI::arrow_keys_to_select_ship); + auto_confirm_ft_upgrade = static_cast(DefaultConfig::UI::auto_confirm_ft_upgrade); + auto_confirm_reveal_ability = + static_cast(DefaultConfig::UI::auto_confirm_reveal_ability); + extend_chest_purchase_max = + static_cast(DefaultConfig::UI::extend_chest_purchase_max); + extend_donation_slider = static_cast(DefaultConfig::UI::extend_donation_slider); + extend_donation_max = static_cast(DefaultConfig::UI::extend_donation_max); + disable_galaxy_chat = static_cast(DefaultConfig::UI::disable_galaxy_chat); + disable_veil_chat = static_cast(DefaultConfig::UI::disable_veil_chat); + show_player_title = static_cast(DefaultConfig::UI::show_player_title); + show_cargo_default = static_cast(DefaultConfig::UI::show_cargo_default); + show_player_cargo = static_cast(DefaultConfig::UI::show_player_cargo); + show_station_cargo = static_cast(DefaultConfig::UI::show_station_cargo); + show_hostile_cargo = static_cast(DefaultConfig::UI::show_hostile_cargo); + show_armada_cargo = static_cast(DefaultConfig::UI::show_armada_cargo); + instant_cargo_counter = static_cast(DefaultConfig::UI::instant_cargo_counter); + cargo_significant_decimals = + static_cast(DefaultConfig::UI::cargo_significant_decimals); + always_skip_reveal_sequence = + static_cast(DefaultConfig::UI::always_skip_reveal_sequence); + disable_exchange_all = static_cast(DefaultConfig::UI::disable_exchange_all); + sync_debug = static_cast(DefaultConfig::Sync::debug); + sync_logging = static_cast(DefaultConfig::Sync::logging); + sync_resolver_cache_ttl = static_cast(DefaultConfig::Sync::resolver_cache_ttl); + config_settings_url = static_cast(DefaultConfig::SystemConfig::settings_url); + config_assets_url_override = + static_cast(DefaultConfig::SystemConfig::assets_url_override); + loader_enabled = static_cast(DefaultConfig::Graphics::loader_enabled); + loader_transition = static_cast(DefaultConfig::Graphics::loader_transition); + loader_transition_black = + static_cast(DefaultConfig::Graphics::loader_transition_black); + loader_image = static_cast(DefaultConfig::Graphics::loader_image); + loader_logo_scale = static_cast(DefaultConfig::Graphics::loader_logo_scale); + galactic_anomaly_timer = + static_cast(DefaultConfig::Graphics::galactic_anomaly_timer); + loader_tip_enabled = static_cast(DefaultConfig::Graphics::loader_tip_enabled); + installInstantWarpConfirmationHooks = true; + auto_confirm_instant_warp = InstantWarpConfirmation::None; + zoom_label_player = {FleetLabelDetail::Native, + static_cast(DefaultConfig::Graphics::zoom_label_player_threshold)}; + zoom_label_non_player = {FleetLabelDetail::Native, + static_cast(DefaultConfig::Graphics::zoom_label_non_player_threshold)}; + bool ignoredAll = false; + ParseShipFilter(DefaultConfig::UI::pinned_ships, pinned_ships, ignoredAll); + ParseShipFilter(DefaultConfig::UI::instant_warp_auto_jump, instant_warp_auto_jump, instant_warp_auto_jump_all); + ParseShipFilter(DefaultConfig::UI::instant_warp_auto_warp, instant_warp_auto_warp, instant_warp_auto_warp_all); + ParseShipFilter(DefaultConfig::UI::instant_warp_always_ask, instant_warp_always_ask, instant_warp_always_ask_all); + ParseFactionFilter(DefaultConfig::UI::daily_bulk_claim_factions, daily_bulk_claim_factions); + ParseBannerFilter(DefaultConfig::UI::disabled_banner_types, disabled_banner_types); + ParseBannerFilter(DefaultConfig::UI::notify_banner_types, notify_banner_types); + alert_victory = notification_sound_from_name(DefaultConfig::Audio::alert_victory).value_or(NotificationSound::None); + alert_defeat = notification_sound_from_name(DefaultConfig::Audio::alert_defeat).value_or(NotificationSound::None); + alert_armada_created = + notification_sound_from_name(DefaultConfig::Audio::alert_armada_created).value_or(NotificationSound::None); + alert_armada_battle_won = + notification_sound_from_name(DefaultConfig::Audio::alert_armada_battle_won).value_or(NotificationSound::None); + alert_armada_battle_lost = + notification_sound_from_name(DefaultConfig::Audio::alert_armada_battle_lost).value_or(NotificationSound::None); +} +Config& Config::Get() +{ + static Config config; + return config; +} +MissionHudVisibility Config::MissionHudButtonVisibility(std::string_view name) const +{ + const auto found = mission_hud_buttons.find(std::string(name)); + return found == mission_hud_buttons.end() ? MissionHudVisibility::Auto : found->second; +} + +const char* File::Config() +{ return "fixture-settings.toml"; } +#if _WIN32 +std::string_view File::MakePath(std::string_view, bool, bool) +{ return "fixture://configuration/fixture-settings.toml"; } +#else +std::u8string File::MakePath(std::string_view, bool, bool) +{ return u8"fixture://configuration/fixture-settings.toml"; } +#endif + +namespace runtime_config +{ +bool RegisterSetting(const char* section, const char* key) noexcept +{ + fixture::registered.insert(std::string(section) + "." + key); + return true; +} +void SaveSetting(const char* section, const char* key, config_edit::Value value, std::chrono::milliseconds) noexcept +{ fixture::writes.push_back({std::string(section) + "." + key, std::move(value)}); } +mod_settings::SaveStatus GetSaveStatus(const char*, const char*) noexcept +{ return {mod_settings::SaveState::Saved, fixture::writes.size()}; } +} // namespace runtime_config +namespace mod_settings +{ +PageCatalog& ModPages() +{ + static PageCatalog catalog("community_mod.settings", "Mod Settings"); + return catalog; +} +bool OpenTextEntry(TextEntryRequest request) +{ + fixture::modal = std::move(request); + return true; +} +void CloseTextEntry() +{ fixture::modal.reset(); } +bool TextEntryActive() +{ return fixture::modal.has_value(); } +} // namespace mod_settings +bool ForbiddenTechControlsAvailable() +{ return true; } +namespace mission_hud +{ +bool Available() +{ return true; } +bool CanChange() +{ return true; } +void Refresh() +{ ++fixture::hudRefreshes; } +} // namespace mission_hud +bool FleetLabelControlsAvailable() +{ return fixture::labelsAvailable; } +void RefreshFleetLabelControls() +{ ++fixture::labelRefreshes; } +void ApplyUiShipScaleToLoadedShips(float before, float after) +{ + ++fixture::shipRefreshes; + fixture::previousShipScale = before; + fixture::nextShipScale = after; +} +void ResetQueueRecoveryTracking() +{ ++fixture::queueTrackingResets; } + +std::string_view notification_sound_name(NotificationSound sound) +{ + static constexpr std::string_view names[]{"none", "default", "info", "success", "warning", + "alarm", "arrival", "soft", "ping", "repair"}; + return names[static_cast(sound)]; +} +std::optional notification_sound_from_name(std::string_view name) +{ + for (int i = 0; i < static_cast(NotificationSound::Count); ++i) + if (notification_sound_name(static_cast(i)) == name) + return static_cast(i); + return {}; +} +void notification_audio_play(NotificationSound) {} + +// Controlled parser boundaries prove the catalog delegates validation and uses +// normalized readback. Startup parser correctness is a separate responsibility. +bool ParseShipFilter(std::string_view input, std::vector& names, bool& all) +{ + ++fixture::parseCalls; + names.clear(); + all = false; + const auto text = fixture::Trim(input); + if (text == "*") { + all = true; + return true; + } + if (text.find('*') != text.npos) + return false; + std::istringstream stream(text); + std::string name; + while (std::getline(stream, name, ',')) { + name = fixture::Trim(name); + if (name.empty()) + continue; + for (auto& c : name) + c = static_cast(std::toupper(static_cast(c))); + names.push_back(name); + } + return true; +} +bool ParseFactionFilter(std::string_view input, std::vector& names) +{ + ++fixture::parseCalls; + names.clear(); + std::istringstream stream{std::string(input)}; + std::string name; + while (std::getline(stream, name, ',')) { + name = fixture::Lower(fixture::Trim(name)); + if (name.empty()) + continue; + if (name != "federation" && name != "klingon" && name != "romulan") + return false; + names.push_back(name); + } + return true; +} +bool ParseBannerFilter(std::string_view input, std::vector& values) +{ + ++fixture::parseCalls; + values.clear(); + const auto name = fixture::Lower(fixture::Trim(input)); + if (name.empty()) + return true; + if (name != "victory") + return false; + values.push_back(1); + return true; +} +std::string FormatBannerFilter(const std::vector& values) +{ return values.empty() ? "" : "Victory"; } + +int main(int argc, char** argv) +{ + using namespace fixture; + using namespace mod_settings; + try { + Require(argc == 3, "usage: upstream_settings_test "); + std::ifstream coverageFile(argv[1]); + Require(coverageFile.good(), "coverage inventory not readable"); + const Json inventory = Json::parse(coverageFile); + std::map expected; + for (const auto& row : inventory.at("settings")) { + if (row.at("treatment") != "in_game") + continue; +#if !_WIN32 + if (row.contains("platforms") && row.at("platforms") == Json::array({"windows"})) + continue; +#endif + Require(expected.emplace(row.at("key").get(), row).second, "duplicate inventory key"); + } +#if _WIN32 + Require(expected.size() == 89, "Windows catalog count changed without a coverage decision"); +#else + Require(expected.size() == 87, "macOS catalog count changed without a coverage decision"); +#endif + const auto reads = NativeReads(); + Require(reads.size() == expected.size(), "native field map missing an inventory key"); + const auto defaults = ReadAll(reads); + Config::Get().installNativeSettings = false; + Require(PersistCurrentSetting("ui", "pinned_ships"), "native pin save failed with settings hooks disabled"); + Require(writes.size() == 1 && writes.back().key == "ui.pinned_ships", "lazy initialization wrote unrelated fields"); + writes.clear(); + auto& catalog = ModPages(); + RegisterUpstreamSettings(catalog); + const auto pages = catalog.Build(); + SettingsSearch search; + search.Build(pages); + Require(!search.Find("ui.show_player_title").empty(), "actual catalog key missing from search"); + std::map models; + for (const auto& page : pages) + for (const auto& item : page.items) { + std::visit( + [&](const auto& value) { + using P = std::decay_t; + if constexpr (std::is_same_v) { + value->Observe(); + models.try_emplace(value->metadata().TomlKey(), Typed(*value)); + } else if constexpr (std::is_same_v) { + value->state().Observe(); + models.try_emplace(value->state().metadata().TomlKey(), + Typed(value->state(), static_cast(value->labels().size()))); + } else if constexpr (std::is_same_v) { + value->state().Observe(); + models.try_emplace(value->state().metadata().TomlKey(), Typed(value->state())); + } else if constexpr (std::is_same_v) { + for (std::size_t i = 0; i < value->Count(); ++i) + value->Read(i); + if (value->metadata) + models.try_emplace(value->metadata->TomlKey(), Exact(*value)); + } + }, + item); + } + Require(writes.empty(), "registration/search/browsing persisted configuration"); + Require(models.size() == expected.size() && registered.size() == expected.size(), + "actual metadata count differs from inventory"); + for (const auto& [key, model] : models) { + Require(expected.contains(key), "unexpected model key: " + key); + Require(!model.metadata->description.empty(), "missing model help: " + key); + Require(model.available(), "startup default unavailable: " + key); + const char* timing = model.metadata->timing == ApplyTiming::Restart ? "restart" + : model.metadata->timing == ApplyTiming::ReopenView ? "reopen_view" + : "live"; + Require(expected.at(key).at("application") == timing, "application timing differs from inventory: " + key); + } + const std::filesystem::path directory(argv[2]); + Require(!std::filesystem::exists(directory), "fixture directory must be new"); + std::filesystem::create_directories(directory); + const auto image = directory / "test-image.png"; + { + std::ofstream file(image); + file << "catalog path boundary fixture"; + } + for (const auto& [key, model] : models) { + writes.clear(); + model.change(expected.at(key), image); + Require(writes.size() == 1 && writes.back().key == key, "edit did not queue exactly its own field: " + key); + const auto after = ReadAll(reads); + for (const auto& [other, before] : defaults) { + const auto target = + other == key && model.metadata->timing != ApplyTiming::Restart ? writes.back().value : before; + Require(after.at(other) == target, "edit changed wrong native field: " + key + " -> " + other); + } + model.reset(); + Require(writes.size() == 2 && writes.back().key == key, "reset did not queue exactly its own field: " + key); + Require(writes.back().value == defaults.at(key), "reset/default serialization mismatch: " + key); + Require(ReadAll(reads) == defaults, "reset did not restore native baseline: " + key); + } + Require(shipRefreshes == 2 && previousShipScale != nextShipScale, "ship scale did not refresh loaded ships"); + Require(queueTrackingResets == 4, "queue protections did not invalidate tracking on both toggle transitions"); + models.at("control.queue_enabled").reset(); + models.at("control.kirshara_fixes").reset(); + Require(queueTrackingResets == 4, "unchanged queue defaults unnecessarily invalidated recovery tracking"); + Require(labelRefreshes > 0 && hudRefreshes > 0 && parseCalls > 0, "native refresh/parser boundaries were bypassed"); + + auto invalid = [&](const std::string& key, const std::string& text) { + auto* action = models.at(key).exact; + Require(action, "validation test requires exact entry"); + writes.clear(); + modal.reset(); + action->invoke(0); + Require(modal.has_value(), "validation modal missing"); + Require(!modal->validate(text).empty() && !modal->commit(text), "invalid input accepted: " + key + " = " + text); + Require(writes.empty() && ReadAll(reads) == defaults, "invalid input mutated state"); + modal.reset(); + }; + for (const auto* text : {"", "nan", "inf", "5garbage", "-1"}) + invalid("graphics.keyboard_zoom_speed", text); + invalid("control.select_timer", "1.5"); + invalid("control.select_timer", "2147483648"); + invalid("ui.cargo_significant_decimals", "7"); + invalid("ui.pinned_ships", "*"); + invalid("ui.daily_bulk_claim_factions", "not-a-faction"); + invalid("ui.disabled_banner_types", "not-a-banner"); + invalid("graphics.loader_image", (directory / "missing.png").string()); + + // A stale exact editor and a stale prepared reset cannot overwrite a later + // native shortcut mutation. No worker/config-file behavior is simulated. + auto* speed = models.at("graphics.keyboard_zoom_speed").exact; + speed->invoke(0); + auto stale = std::move(*modal); + modal.reset(); + auto reset = speed->prepare_reset(); + Config::Get().keyboard_zoom_speed += 25; + writes.clear(); + Require(!stale.commit("475") && !reset.current() && !reset.apply(), "stale edit/reset overwrote native change"); + Require(writes.empty(), "stale action persisted state"); + Config::Get().keyboard_zoom_speed = DefaultConfig::Graphics::keyboard_zoom_speed; + labelsAvailable = false; + Require(!models.at("graphics.zoom_label_player_detail").available(), + "missing fleet-label hook was advertised as supported"); + labelsAvailable = true; + writes.clear(); + Require(!PersistCurrentSetting("unknown", "key") && writes.empty(), "unknown key was persisted"); + std::filesystem::remove(image); + std::cout << "Actual upstream catalog passed: " << models.size() + << " keys; defaults, field mapping, timing, serialization, validation and stale guards verified.\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << "Catalog fixture failed: " << error.what() << '\n'; + return 1; + } +} From ac735374fb9f4713dcc1c21429ec150f4aa51518 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 03:57:07 -0500 Subject: [PATCH 02/17] Handle settings save failures, restart gates and input release --- docs/MOD_SETTINGS.md | 7 ++- docs/settings-coverage.json | 7 +-- mods/src/config.cc | 58 ++++++--------------- mods/src/config.h | 2 +- mods/src/config_save.cc | 13 +++++ mods/src/config_save.h | 4 ++ mods/src/patches/key.cc | 8 +++ mods/src/patches/key.h | 1 + mods/src/patches/mapkey.cc | 13 ++++- mods/src/patches/mapkey.h | 2 + mods/src/patches/optional_shortcuts.h | 43 +++++++++++++++ mods/src/patches/parts/runtime_config.cc | 24 ++++++++- mods/src/runtime_config_writer.cc | 10 ++++ mods/src/runtime_config_writer.h | 3 ++ mods/src/settings/input_release.h | 33 ++++++++++++ mods/src/settings/native/row_style.cc | 6 +-- mods/src/settings/native/settings_search.cc | 54 ++++++++++++++----- mods/src/settings/native/settings_search.h | 3 +- mods/src/settings/native/shortcut_popup.cc | 2 + mods/src/settings/native/text_entry.cc | 20 ++----- mods/src/settings/native/value_widgets.cc | 14 +---- mods/src/settings/native_view_state.h | 14 +++++ mods/src/settings/shortcut_settings.cc | 4 ++ mods/src/settings/upstream_settings.cc | 16 +++--- tests/config_save_failure_test.cc | 3 ++ tests/config_save_test.cc | 5 ++ tests/input_release_test.cc | 20 +++++++ tests/run-settings.ps1 | 14 +++-- tests/runtime_config_fixture.h | 7 +++ tests/runtime_config_test.cc | 6 +++ tests/runtime_config_writer_test.cc | 25 +++++++++ tests/settings_metadata_test.cc | 19 +++++++ tests/shortcut_hint_cache.cc | 28 +++++++++- tests/test_settings_coverage.py | 7 +-- tests/upstream_settings_test.cc | 8 ++- 35 files changed, 389 insertions(+), 114 deletions(-) create mode 100644 mods/src/patches/optional_shortcuts.h create mode 100644 mods/src/settings/input_release.h create mode 100644 tests/input_release_test.cc diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md index 393bc0cdd..80286d082 100644 --- a/docs/MOD_SETTINGS.md +++ b/docs/MOD_SETTINGS.md @@ -8,6 +8,8 @@ The editor uses the same configuration values and defaults as the mod's TOML con Ordinary controls apply to the current session and save automatically. Each control reports its own save state. A failed save or an external edit conflict remains visible; another successful save does not clear it. Resolve a conflicting external edit before retrying. Unrelated TOML values and comments are preserved. +Slider and scale-shortcut changes apply immediately and save after 250 ms without another change to that value. Orderly exit flushes pending changes. If the game vetoes a quit request, saving resumes for the continuing session. A failure to write the generated runtime-vars diagnostic does not prevent startup with a readable configuration. + Some controls take effect when their screen is reopened. Restart controls retain the requested preference separately from the active value, so a saved restart change is not presented as already active. Examples include native-game shortcut mode, window setup, loading assets, cargo formatting, officer sort initialization, and chat channel suppression. Open a page's **Defaults & Help** to read setting details, restore one default, retry a failed save, or review a page reset. Reset uses the default declared by the setting. A page reset applies each available setting once, including a number shown both as an exact entry and a convenience slider. The confirmation reports unavailable settings it will skip. If an included setting changes while confirmation is open, confirm again from a fresh dialog. Changes save individually; the page reset is not a transactional file batch. It leaves unrelated settings and other pages alone. @@ -26,7 +28,7 @@ The shortcut editor covers the mod's registered actions, including actions curre Recording waits for keys to be released. Confirm the proposed binding to apply it, or cancel with Escape. Losing application focus cancels recording. Gameplay shortcuts remain suppressed while an editor owns keyboard input and until held keys have been released. -Overlap warnings help identify shared keys. Some actions intentionally share bindings in different contexts, so warnings do not remove another binding automatically. Unsupported native adapters leave the relevant action unavailable for that session. Changing to or from the game's own shortcut mode requires restart. +Overlap warnings help identify shared keys. Some actions intentionally share bindings in different contexts, so warnings do not remove another binding automatically. Unsupported native adapters leave the relevant action unavailable for that session. Changing extended shortcut availability or the game's own shortcut mode requires restart. Experimental actions remain visible when disabled and require their TOML option and restart. ## Advanced settings kept in TOML @@ -38,7 +40,8 @@ These categories are explained in the Advanced page rather than exposed as ordin | `[sync]` and `[sync.targets.]` | Dynamic service targets, credentials, connection options, and worker configuration require a separate secret-safe editor. Token values are never shown in the settings UI. Restart after editing. | | `audio.trace_events`, `audio.disabled_events` | Diagnostic event tracing and arbitrary internal event-name filters remain advanced configuration. | | `[config]` endpoint overrides | Configuration/distribution infrastructure is configured before the in-game editor starts. Restart after editing. | -| `control.enable_experimental`, `graphics.system_pan_momentum` | Legacy parser inputs with no current gameplay consumer. The functional pan-glide option is `graphics.system_pan_momentum_falloff`. | +| `control.enable_experimental` | Enables experimental shortcuts at startup. Configure it in TOML and restart. Disabled actions remain visible with a reason. | +| `graphics.system_pan_momentum` | Legacy parser input with no current gameplay consumer. The functional pan-glide option is `graphics.system_pan_momentum_falloff`. | `graphics.free_resize` and `graphics.borderless_fullscreen` are Windows options. The macOS editor explains their platform scope instead of offering controls that cannot affect that client. diff --git a/docs/settings-coverage.json b/docs/settings-coverage.json index 3abee2173..ea7ae10b6 100644 --- a/docs/settings-coverage.json +++ b/docs/settings-coverage.json @@ -13,9 +13,9 @@ {"key": "buffs.use_out_of_dock_power", "control": "boolean", "page": "officers", "application": "live", "treatment": "in_game"}, {"key": "config.assets_url_override", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "config"}, {"key": "config.settings_url", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "config"}, - {"key": "control.enable_experimental", "treatment": "toml_only", "application": "restart", "page": "advanced", "policy": "legacy_experimental"}, + {"key": "control.enable_experimental", "treatment": "toml_only", "application": "restart", "page": "advanced", "policy": "experimental_shortcuts"}, {"key": "control.hotkeys_enabled", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, - {"key": "control.hotkeys_extended", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, + {"key": "control.hotkeys_extended", "control": "boolean", "page": "controls", "application": "restart", "treatment": "in_game"}, {"key": "control.kirshara_fixes", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, {"key": "control.primary_requires_visible_target", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, {"key": "control.queue_enabled", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, @@ -314,7 +314,8 @@ "config": "Advanced configuration/distribution endpoint override; not an ordinary player preference.", "patches": "Emergency hook-install compatibility switch. Restart is required; not a live feature preference.", "sync": "Network target configuration and diagnostics remain with the secret-bearing sync configuration. Active workers may snapshot these values; no credentials are rendered.", - "legacy_experimental": "Retained startup compatibility inputs with no current gameplay consumer. Do not imply an in-game control changes behavior." + "legacy_experimental": "Legacy parser input without a current gameplay consumer.", + "experimental_shortcuts": "Controls startup availability of experimental shortcut actions. Edit control.enable_experimental in TOML and restart; disabled actions remain visible with a reason." }, "shortcuts_policy": { "treatment": "in_game", diff --git a/mods/src/config.cc b/mods/src/config.cc index 59cc0d1e6..d32f672fe 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -2,6 +2,7 @@ #include "config_save.h" #include "file.h" #include "patches/mapkey.h" +#include "patches/optional_shortcuts.h" #include "patches/runtime_config.h" #include "prime/KeyCode.h" #include "settings/upstream_settings.h" @@ -125,7 +126,7 @@ Config::Config() Load(); } -void Config::Save(const toml::table& config, const std::string_view filename, bool apply_warning) +bool Config::Save(const toml::table& config, const std::string_view filename, bool apply_warning) { auto config_path = File::MakePath(filename, true); std::ostringstream config_file; @@ -150,7 +151,12 @@ void Config::Save(const toml::table& config, const std::string_view filename, bo config_file << "#######################################################################\n\n"; } - SaveConfigDocument(config, config_path, config_file.str()); + std::string error; + const bool saved = SaveStartupConfigDocument(config, config_path, config_file.str(), error); + if (!saved) + spdlog::warn("Could not write startup configuration output '{}': {}; loaded settings remain available", filename, + error); + return saved; } Config& Config::Get() @@ -1488,53 +1494,23 @@ void Config::Load() parse_config_shortcut(config, parsed, "toggle_instant_warp", GameFunction::ToggleAutoConfirmInstantWarp, DCSH::toggle_instant_warp); - if (this->hotkeys_extended) { - parse_config_shortcut(config, parsed, "show_alliance", GameFunction::ShowAlliance, DCSH::show_alliance); - - if (this->enable_experimental) { - parse_config_shortcut(config, parsed, "show_alliance_help", GameFunction::ShowAllianceHelp, - DCSH::show_alliance_help); - parse_config_shortcut(config, parsed, "show_alliance_armada", GameFunction::ShowAllianceArmada, - DCSH::show_alliance_armada); - } - - parse_config_shortcut(config, parsed, "show_bookmarks", GameFunction::ShowBookmarks, DCSH::show_bookmarks); - - if (this->enable_experimental) { - parse_config_shortcut(config, parsed, "show_lookup", GameFunction::ShowLookup, DCSH::show_lookup); - } - - parse_config_shortcut(config, parsed, "set_zoom_preset1", GameFunction::SetZoomPreset1, DCSH::set_zoom_preset1); - parse_config_shortcut(config, parsed, "set_zoom_preset2", GameFunction::SetZoomPreset2, DCSH::set_zoom_preset2); - parse_config_shortcut(config, parsed, "set_zoom_preset3", GameFunction::SetZoomPreset3, DCSH::set_zoom_preset3); - parse_config_shortcut(config, parsed, "set_zoom_preset4", GameFunction::SetZoomPreset4, DCSH::set_zoom_preset4); - parse_config_shortcut(config, parsed, "set_zoom_preset5", GameFunction::SetZoomPreset5, DCSH::set_zoom_preset5); - parse_config_shortcut(config, parsed, "set_zoom_default", GameFunction::SetZoomDefault, DCSH::set_zoom_default); - parse_config_shortcut(config, parsed, "toggle_preview_locate", GameFunction::TogglePreviewLocate, - DCSH::toggle_preview_locate); - parse_config_shortcut(config, parsed, "toggle_preview_recall", GameFunction::TogglePreviewRecall, - DCSH::toggle_preview_recall); - parse_config_shortcut(config, parsed, "toggle_cargo_default", GameFunction::ToggleCargoDefault, - DCSH::toggle_cargo_default); - parse_config_shortcut(config, parsed, "toggle_cargo_player", GameFunction::ToggleCargoPlayer, - DCSH::toggle_cargo_player); - parse_config_shortcut(config, parsed, "toggle_cargo_station", GameFunction::ToggleCargoStation, - DCSH::toggle_cargo_station); - parse_config_shortcut(config, parsed, "toggle_cargo_hostile", GameFunction::ToggleCargoHostile, - DCSH::toggle_cargo_hostile); - parse_config_shortcut(config, parsed, "toggle_cargo_armada", GameFunction::ToggleCargoArmada, - DCSH::toggle_cargo_armada); - } + shortcut_options::LoadAll(this->hotkeys_extended, this->enable_experimental, + [&](const shortcut_options::Definition& definition, bool enabled) { + parse_config_shortcut(config, parsed, definition.key, definition.action, + definition.default_binding); + MapKey::SetActionEnabled(definition.action, enabled); + }); spdlog::debug(""); const bool created_config = !std::filesystem::exists(File::MakePath(File::Config())); + bool created_config_saved = false; if (created_config) { message.str(""); message << "Creating " << File::Config() << " (default config file)"; spdlog::warn(message.str()); - Config::Save(parsed, File::Config(), false); + created_config_saved = Config::Save(parsed, File::Config(), false); } message.str(""); @@ -1550,7 +1526,7 @@ void Config::Load() } Config::Save(parsed, File::Vars()); - runtime_config::Configure(created_config ? parsed : config); + runtime_config::Configure(created_config_saved ? parsed : config); std::cout << "\n\n-----------------------------\n\n" << parsed << "\n\n-----------------------------\nVersion " diff --git a/mods/src/config.h b/mods/src/config.h index 173d8795d..abc23a823 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -159,7 +159,7 @@ class Config final [[nodiscard]] static HWND WindowHandle(); #endif - static void Save(const toml::table& config, std::string_view filename, bool apply_warning = true); + static bool Save(const toml::table& config, std::string_view filename, bool apply_warning = true); void Load(); void AdjustUiScale(bool scaleUp); void AdjustUiShipScale(bool scaleUp); diff --git a/mods/src/config_save.cc b/mods/src/config_save.cc index 54a084229..9004c6d8f 100644 --- a/mods/src/config_save.cc +++ b/mods/src/config_save.cc @@ -31,6 +31,19 @@ void SaveConfigDocument(const toml::table& config, const std::filesystem::path& ReplaceConfigText(path, bytes); } +bool SaveStartupConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header, + std::string& error) +{ + try { + SaveConfigDocument(config, path, header); + error.clear(); + return true; + } catch (const std::exception& failure) { + error = failure.what(); + return false; + } +} + std::string ReadConfigText(const std::filesystem::path& path) { std::ifstream input(path, std::ios::binary); diff --git a/mods/src/config_save.h b/mods/src/config_save.h index 8e87aa8a3..0ef41d156 100644 --- a/mods/src/config_save.h +++ b/mods/src/config_save.h @@ -9,6 +9,10 @@ // Synchronous whole-document output for startup, not a runtime setting editor. // Throws on failure; the caller owns reporting. Does not guarantee power-loss durability. void SaveConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header = {}); +// Startup outputs must not prevent a readable configuration from loading. +// Returns the checked writer's error for the caller to report. +bool SaveStartupConfigDocument(const toml::table& config, const std::filesystem::path& path, std::string_view header, + std::string& error); std::string ReadConfigText(const std::filesystem::path& path); // Preserves the supplied text. With expected text, false means an external edit diff --git a/mods/src/patches/key.cc b/mods/src/patches/key.cc index 4ca578d4d..d5e7cdb1c 100644 --- a/mods/src/patches/key.cc +++ b/mods/src/patches/key.cc @@ -343,6 +343,14 @@ std::string Key::Token(KeyCode key) return result; } +bool Key::AnyRawPressed() +{ + for (const auto& [token, key] : mappedKeys) + if (key != KeyCode::None && RawPressed(key)) + return true; + return false; +} + bool Key::IsInputFocused() { EnsureCurrentFrame(); diff --git a/mods/src/patches/key.h b/mods/src/patches/key.h index 40151448f..d65d9f2cb 100644 --- a/mods/src/patches/key.h +++ b/mods/src/patches/key.h @@ -30,6 +30,7 @@ class Key static bool Down(KeyCode key); // Editors alone sample raw input; every ordinary consumer respects ownership. static bool RawPressed(KeyCode key); + static bool AnyRawPressed(); static bool RawDown(KeyCode key); static std::string Token(KeyCode key); inline static bool shortcutCaptureActive = false, shortcutPopupActive = false; diff --git a/mods/src/patches/mapkey.cc b/mods/src/patches/mapkey.cc index 98bbee315..a175811b1 100644 --- a/mods/src/patches/mapkey.cc +++ b/mods/src/patches/mapkey.cc @@ -175,6 +175,8 @@ std::string MapKey::GetShortcuts(GameFunction gameFunction) std::string MapKey::GetShortcutHint(GameFunction gameFunction) { + if (!Definition(gameFunction).enabled) + return {}; const auto& mapKeys = MapKey::mappedKeys[gameFunction]; return mapKeys.empty() ? "" : mapKeys.front().shortcutHint; } @@ -191,6 +193,8 @@ void MapKey::CacheShortcutHints() bool MapKey::HasBinding(GameFunction gameFunction) { + if (!Definition(gameFunction).enabled) + return false; for (const auto& mapKey : mappedKeys[gameFunction]) { if (mapKey.Key != KeyCode::None) { return true; @@ -210,12 +214,15 @@ void MapKey::RegisterAction(GameFunction gameFunction, std::string_view key, std const MapKey::ActionDefinition& MapKey::Definition(GameFunction gameFunction) { return definitions.at(static_cast(gameFunction)); } +void MapKey::SetActionEnabled(GameFunction action, bool enabled) +{ definitions.at(static_cast(action)).enabled = enabled; } + const std::vector& MapKey::Bindings(GameFunction gameFunction) { return mappedKeys.at(static_cast(gameFunction)); } bool MapKey::ReplaceBindings(GameFunction gameFunction, std::vector bindings) { - if (gameFunction < 0 || gameFunction >= GameFunction::Max) + if (gameFunction < 0 || gameFunction >= GameFunction::Max || !Definition(gameFunction).enabled) return false; for (const auto& binding : bindings) if (binding.Key == KeyCode::None) @@ -236,6 +243,8 @@ static bool IsMovementAction(GameFunction action) bool MapKey::IsPressed(GameFunction gameFunction) { + if (!Definition(gameFunction).enabled) + return false; const auto &mapKeys = MapKey::mappedKeys[(int)gameFunction]; for (const MapKey &mapKey : mapKeys) { if (mapKey.Key != KeyCode::None) { @@ -255,6 +264,8 @@ bool MapKey::IsPressed(GameFunction gameFunction) bool MapKey::IsDown(GameFunction gameFunction) { + if (!Definition(gameFunction).enabled) + return false; const auto &mapKeys = MapKey::mappedKeys[(int)gameFunction]; for (const MapKey &mapKey : mapKeys) { if (mapKey.Key != KeyCode::None) { diff --git a/mods/src/patches/mapkey.h b/mods/src/patches/mapkey.h index 465a5f2a7..6fdd1661b 100644 --- a/mods/src/patches/mapkey.h +++ b/mods/src/patches/mapkey.h @@ -14,6 +14,7 @@ class MapKey public: struct ActionDefinition { std::string key, defaultBinding; + bool enabled = true; }; MapKey(); @@ -21,6 +22,7 @@ class MapKey static void AddMappedKey(GameFunction gameFunction, MapKey mappedKey); static void RegisterAction(GameFunction action, std::string_view key, std::string_view defaultBinding); static const ActionDefinition& Definition(GameFunction action); + static void SetActionEnabled(GameFunction action, bool enabled); static const std::vector& Bindings(GameFunction action); static bool ReplaceBindings(GameFunction action, std::vector bindings); static bool SameBinding(const MapKey& first, const MapKey& second); diff --git a/mods/src/patches/optional_shortcuts.h b/mods/src/patches/optional_shortcuts.h new file mode 100644 index 000000000..e6ea5a057 --- /dev/null +++ b/mods/src/patches/optional_shortcuts.h @@ -0,0 +1,43 @@ +#pragma once +#include "defaultconfig.h" +#include "patches/gamefunctions.h" +#include +#include + +namespace shortcut_options +{ +struct Definition { + GameFunction action; + std::string_view key, default_binding; + bool experimental; +}; +inline constexpr auto definitions = std::to_array({ + {GameFunction::ShowAlliance, "show_alliance", DefaultConfig::Shortcuts::show_alliance, false}, + {GameFunction::ShowAllianceHelp, "show_alliance_help", DefaultConfig::Shortcuts::show_alliance_help, true}, + {GameFunction::ShowAllianceArmada, "show_alliance_armada", DefaultConfig::Shortcuts::show_alliance_armada, true}, + {GameFunction::ShowBookmarks, "show_bookmarks", DefaultConfig::Shortcuts::show_bookmarks, false}, + {GameFunction::ShowLookup, "show_lookup", DefaultConfig::Shortcuts::show_lookup, true}, + {GameFunction::SetZoomPreset1, "set_zoom_preset1", DefaultConfig::Shortcuts::set_zoom_preset1, false}, + {GameFunction::SetZoomPreset2, "set_zoom_preset2", DefaultConfig::Shortcuts::set_zoom_preset2, false}, + {GameFunction::SetZoomPreset3, "set_zoom_preset3", DefaultConfig::Shortcuts::set_zoom_preset3, false}, + {GameFunction::SetZoomPreset4, "set_zoom_preset4", DefaultConfig::Shortcuts::set_zoom_preset4, false}, + {GameFunction::SetZoomPreset5, "set_zoom_preset5", DefaultConfig::Shortcuts::set_zoom_preset5, false}, + {GameFunction::SetZoomDefault, "set_zoom_default", DefaultConfig::Shortcuts::set_zoom_default, false}, + {GameFunction::TogglePreviewLocate, "toggle_preview_locate", DefaultConfig::Shortcuts::toggle_preview_locate, + false}, + {GameFunction::TogglePreviewRecall, "toggle_preview_recall", DefaultConfig::Shortcuts::toggle_preview_recall, + false}, + {GameFunction::ToggleCargoDefault, "toggle_cargo_default", DefaultConfig::Shortcuts::toggle_cargo_default, false}, + {GameFunction::ToggleCargoPlayer, "toggle_cargo_player", DefaultConfig::Shortcuts::toggle_cargo_player, false}, + {GameFunction::ToggleCargoStation, "toggle_cargo_station", DefaultConfig::Shortcuts::toggle_cargo_station, false}, + {GameFunction::ToggleCargoHostile, "toggle_cargo_hostile", DefaultConfig::Shortcuts::toggle_cargo_hostile, false}, + {GameFunction::ToggleCargoArmada, "toggle_cargo_armada", DefaultConfig::Shortcuts::toggle_cargo_armada, false}, +}); +// Always load metadata and configured bindings; dispatch retains its startup +// availability. Disabled actions remain discoverable with a reason in Settings. +template void LoadAll(bool extended, bool experimental, Load load) +{ + for (const auto& definition : definitions) + load(definition, extended && (!definition.experimental || experimental)); +} +} // namespace shortcut_options diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc index 1593b129d..68de19afa 100644 --- a/mods/src/patches/parts/runtime_config.cc +++ b/mods/src/patches/parts/runtime_config.cc @@ -89,8 +89,18 @@ bool WantsQuit(auto original) } const bool allows = original(); std::lock_guard lock(lifecycle); - if (stopped || !writer) + if (!writer) return allows; + if (stopped) { + if (!allows) { + // Unity may veto the resumed request. The surviving session must regain + // saving after the old worker has fully terminated. + stopped = false; + draining = true; + resume = false; + } + return allows; + } // A later/nested game veto must not be overwritten by an older returning vote. if (this_vote == vote) resume = allows; @@ -132,9 +142,19 @@ void Update() std::lock_guard lock(lifecycle); if (!draining || stopped || !writer || !writer->PollStopped()) return; - stopped = true; quit = resume; resume = false; // Consume before Unity callbacks; never retry a genuine veto. + if (!quit) { + draining = false; + stopped = false; + if (!writer->Resume()) { + available = false; + persistence_unavailable.store(true); + ++adapter_status_sequence; + } + return; + } + stopped = true; } if (quit) request_quit(0); diff --git a/mods/src/runtime_config_writer.cc b/mods/src/runtime_config_writer.cc index 3ea12f883..3b849cc9b 100644 --- a/mods/src/runtime_config_writer.cc +++ b/mods/src/runtime_config_writer.cc @@ -217,6 +217,16 @@ bool RuntimeConfigWriter::PollStopped() return true; } +bool RuntimeConfigWriter::Resume() +{ + std::lock_guard lock(mutex_); + if (worker_.joinable() || !stopping_ || cancel_pending_.load() || !pending_.empty()) + return false; + stopping_ = false; + finished_.store(false); + return true; +} + #if _WIN32 void* RuntimeConfigWriter::NativeHandle() { return worker_.joinable() ? worker_.native_handle() : nullptr; } diff --git a/mods/src/runtime_config_writer.h b/mods/src/runtime_config_writer.h index e3992b5f1..5ffc24e8b 100644 --- a/mods/src/runtime_config_writer.h +++ b/mods/src/runtime_config_writer.h @@ -51,6 +51,9 @@ class RuntimeConfigWriter // Owner thread only, like Submit. On Windows this observes native thread exit // before joining; it never joins a still-running worker on a game callback. bool PollStopped(); + // After PollStopped confirms termination, a native quit veto can reopen + // admission without discarding acknowledged values or per-key failures. + bool Resume(); #if _WIN32 void* NativeHandle(); // Owner thread only; caller must duplicate before retaining. #endif diff --git a/mods/src/settings/input_release.h b/mods/src/settings/input_release.h new file mode 100644 index 000000000..045cb7ac1 --- /dev/null +++ b/mods/src/settings/input_release.h @@ -0,0 +1,33 @@ +#pragma once + +namespace mod_settings +{ +// A blur or destroyed editor does not release keys still held by its user. +// Require a focused, input-free frame before returning input to gameplay. +class InputRelease +{ +public: + void Hold() + { + active_ = true; + idle_frame_ = false; + } + bool active() const + { return active_; } + bool Tick(bool focused, bool held) + { + if (!active_) + return true; + if (!focused || held) + idle_frame_ = false; + else if (idle_frame_) + active_ = false; + else + idle_frame_ = true; + return !active_; + } + +private: + bool active_ = false, idle_frame_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/native/row_style.cc b/mods/src/settings/native/row_style.cc index e88959685..b0a7fe030 100644 --- a/mods/src/settings/native/row_style.cc +++ b/mods/src/settings/native/row_style.cc @@ -145,11 +145,7 @@ void StyleChoice(ValueWidget& view) Root check(Target(view.choiceCheck)); TintImage(view.checkTint, check.get(), view.pressed ? color{0.22f, 0.22f, 0.22f, 1} : color{0.88f, 0.95f, 0.97f, 1}, false); - std::string text = view.state->label(); - if (!view.state->known()) - text += " — " + std::string(view.state->unavailableReason()); - else if (view.state->failed()) - text += " — Retry"; + std::string text = view.state->presentation_label(); if (view.state->value().value_or(false)) text = "" + text + ""; text = std::string(view.pressed ? "" : "") + text + ""; diff --git a/mods/src/settings/native/settings_search.cc b/mods/src/settings/native/settings_search.cc index 755d3aa7e..2e15439a9 100644 --- a/mods/src/settings/native/settings_search.cc +++ b/mods/src/settings/native/settings_search.cc @@ -4,6 +4,7 @@ #include "page_navigation.h" #include "patches/key.h" #include "patches/screen_update_hook.h" +#include "settings/input_release.h" #include "settings/settings_search.h" #include "str_utils.h" #include "ui_helpers.h" @@ -29,7 +30,10 @@ namespace Il2CppGCHandle object{}, input{}, owner{}, panel{}, back{}; Vec2 panelOffset{}; bool installed = false, movedPanel = false, backHeld = false, backEnabled = false; - int releaseFrames = 0; + InputRelease release; + bool (*focused)() = nullptr; + Il2CppGCHandle drainingBack = nullptr; + bool drainingBackEnabled = false; template T ReadValue(Il2CppObject* component, const char* name, const char* type) { @@ -43,8 +47,9 @@ namespace if (backHeld && Alive(Target(back))) Value(Target(back), "set_enabled", backEnabled); backHeld = false; - releaseFrames = 0; - Key::settingsSearchActive = false; + if (Alive(Target(drainingBack))) + Value(Target(drainingBack), "set_enabled", drainingBackEnabled); + Free(drainingBack); } void SetQuery(std::string text) { @@ -97,9 +102,20 @@ namespace } void Tick() { - if (!object && !backHeld) + if (!object && !release.active()) return; try { + if (!object) { + if (release.Tick(focused && focused(), Key::AnyRawPressed())) { + ReleaseBack(); + Key::settingsSearchActive = false; + } + return; + } + if (!focused || !focused()) { + CloseSettingsSearch(); + return; + } if (!Alive(Target(object)) || !Alive(Target(owner)) || !Boolean(UiCall(Target(object), "get_activeInHierarchy"))) { CloseSettingsSearch(); @@ -113,10 +129,10 @@ namespace Value(Target(back), "set_enabled", false); } Key::settingsSearchActive = true; - releaseFrames = 2; - } else if (backHeld && !Key::RawPressed(KeyCode::Escape) && !Key::RawPressed(KeyCode::Return)) { - if (--releaseFrames <= 0) - ReleaseBack(); + release.Hold(); + } else if (release.Tick(true, Key::AnyRawPressed())) { + ReleaseBack(); + Key::settingsSearchActive = false; } Root text(UiCall(Target(input), "get_text")); if (!text.get() || !Type(il2cpp_class_get_type(text.get()->klass), IL2CPP_TYPE_STRING)) @@ -174,11 +190,21 @@ void RegisterSettingsSearch(PageCatalog& catalog) } bool FilterSettingsSearch(const PageCatalog::Page& page, std::string_view id) { return page.id != rootId || query.empty() || id == resultsId || id == "community_mod.save_notice"; } -void CloseSettingsSearch() +void CloseSettingsSearch(bool transfer_navigation) { pending.reset(); try { - ReleaseBack(); + if (transfer_navigation || !release.active()) + ReleaseBack(); + else if (backHeld && Target(back)) { + Free(drainingBack); + drainingBack = il2cpp_gchandle_new_weakref(Target(back), false); + drainingBackEnabled = backEnabled; + if (!drainingBack) + ReleaseBack(); + else + backHeld = false; + } if (movedPanel && Alive(Target(panel))) Value(Target(panel), "set_offsetMax", panelOffset); if (Alive(Target(object))) { @@ -191,7 +217,9 @@ void CloseSettingsSearch() } movedPanel = false; backHeld = false; - Key::settingsSearchActive = false; + // Destruction/navigation only releases the UI. Input stays owned through + // focus recovery and release of every supported key/button. + Key::settingsSearchActive = release.active(); for (auto* handle : {&object, &input, &owner, &panel, &back}) Free(*handle); } @@ -200,6 +228,7 @@ void OpenSettingsSearch(Il2CppObject* controller) if (!installed || !ActionsActive()) return; try { + ReleaseBack(); Retain(owner, controller); Retain(back, ReadField(controller, Field(controller->klass, "_backButtonViewController"))); Root nativePanel(ReadField(controller, PageMeta().panel)); @@ -260,7 +289,8 @@ void OpenSettingsSearch(Il2CppObject* controller) void InstallSettingsSearch() { index.Build(Pages()); - installed = register_screen_manager_update_callback(Tick); + focused = il2cpp_resolve_icall_typed("UnityEngine.Application::get_isFocused()"); + installed = focused && register_screen_manager_update_callback(Tick); } } // namespace mod_settings::native #endif diff --git a/mods/src/settings/native/settings_search.h b/mods/src/settings/native/settings_search.h index b7f68ab46..1b6a64dfa 100644 --- a/mods/src/settings/native/settings_search.h +++ b/mods/src/settings/native/settings_search.h @@ -7,7 +7,8 @@ namespace mod_settings::native void RegisterSettingsSearch(PageCatalog& catalog); void InstallSettingsSearch(); void OpenSettingsSearch(Il2CppObject* controller); -void CloseSettingsSearch(); +// A modal can synchronously take over Back while raw input continues draining. +void CloseSettingsSearch(bool transfer_navigation = false); bool FilterSettingsSearch(const PageCatalog::Page& page, std::string_view id); void NavigateToSearchResult(std::string_view page, std::string_view item); } // namespace mod_settings::native diff --git a/mods/src/settings/native/shortcut_popup.cc b/mods/src/settings/native/shortcut_popup.cc index 21a2bacbc..8fc27f365 100644 --- a/mods/src/settings/native/shortcut_popup.cc +++ b/mods/src/settings/native/shortcut_popup.cc @@ -5,6 +5,7 @@ #include "patches/screen_update_hook.h" #include "prime/Vector3.h" #include "row_style.h" +#include "settings_search.h" #include "settings/native_boolean_callback.h" #include "settings/shortcut_popup_keys.h" #include "ui_helpers.h" @@ -296,6 +297,7 @@ bool OpenShortcutPopup(ShortcutPopupCommands commands) if (!installed || !OnUIThread() || popup.commands.read || !InvokingActionWidget()) return false; try { + CloseSettingsSearch(true); Root host(InvokingActionWidget()); Root hostObject(UiCall(host.get(), "get_gameObject")); Root settings(WithType(hostObject.get(), "GetComponentInParent", diff --git a/mods/src/settings/native/text_entry.cc b/mods/src/settings/native/text_entry.cc index 7c824c783..f1434135f 100644 --- a/mods/src/settings/native/text_entry.cc +++ b/mods/src/settings/native/text_entry.cc @@ -64,20 +64,6 @@ namespace editor.releaseFrame = false; Key::textEntryActive = false; } - bool AnyHeld() - { - static const auto supported = [] { - std::vector keys; - for (int i = 1; i < static_cast(KeyCode::Max); ++i) - if (!Key::Token(static_cast(i)).empty()) - keys.push_back(static_cast(i)); - return keys; - }(); - for (const auto key : supported) - if (Key::RawPressed(key)) - return true; - return false; - } void HoldInput(Il2CppObject* back) { auto* cls = Class("UnityEngine.UI", "UnityEngine.EventSystems", "EventSystem"); @@ -253,7 +239,7 @@ namespace try { if (!editor.request) { // Consume the dismissal frame and all held keys, including a mouse click. - if (!focused() || AnyHeld()) + if (!focused() || Key::AnyRawPressed()) editor.releaseFrame = false; else if (editor.releaseFrame) ReleaseInput(); @@ -268,7 +254,7 @@ namespace } // A key or pointer click that opened the editor cannot also apply/cancel it. if (editor.opening) { - if (AnyHeld()) + if (Key::AnyRawPressed()) return; editor.opening = false; } @@ -320,7 +306,7 @@ bool OpenTextEntry(TextEntryRequest request) if (!installed) return false; } - CloseSettingsSearch(); + CloseSettingsSearch(true); Root host(InvokingActionWidget()); Root hostObject(UiCall(host.get(), "get_gameObject")); Root settings(WithType(hostObject.get(), "GetComponentInParent", diff --git a/mods/src/settings/native/value_widgets.cc b/mods/src/settings/native/value_widgets.cc index ce308cfc4..c38ff8cc1 100644 --- a/mods/src/settings/native/value_widgets.cc +++ b/mods/src/settings/native/value_widgets.cc @@ -352,19 +352,7 @@ void Render(ValueWidget& view, auto original, Il2CppObject* widget) if (Target(view.widget) != widget || Target(view.context) != boundContext.get()) return; Root label(Target(view.label)); - std::string text = view.state->label(); - // The native row has limited label width: "Change not applied; try again" was - // visibly truncated after "; tr" alongside the FC label. Keep these suffixes - // short; recheck the full label at supported UI scales when changing wording. - if (!view.state->known()) - text += " — " + std::string(view.state->unavailableReason()); - else if (view.state->failed()) - text += " — Retry"; - else if (!view.state->enabled() && !view.state->disabledReason().empty()) - text += " — " + std::string(view.state->disabledReason()); - const auto status = view.state->status_text(); - if (!status.empty()) - text += "\n" + status + ""; + std::string text = view.state->presentation_label(); // An enabled slider belongs to the selected mode above it. Use a quiet cyan // accent, not the native white selection fill (the slider is not a choice). if (WidgetMeta(widget).slider && view.state->enabled()) { diff --git a/mods/src/settings/native_view_state.h b/mods/src/settings/native_view_state.h index 97f054b52..dd75fee31 100644 --- a/mods/src/settings/native_view_state.h +++ b/mods/src/settings/native_view_state.h @@ -115,6 +115,20 @@ class NativeViewState : choice_ ? choice_->setting().status_text() : slider_->setting().status_text(); } + std::string presentation_label() const + { + auto text = label(); + if (!known()) + text += " — " + std::string(unavailableReason()); + else if (failed()) + text += " — Retry"; + else if (!enabled() && !disabledReason().empty()) + text += " — " + std::string(disabledReason()); + const auto status = status_text(); + if (!status.empty()) + text += "\n" + status + ""; + return text; + } class RenderScope { public: diff --git a/mods/src/settings/shortcut_settings.cc b/mods/src/settings/shortcut_settings.cc index 290e9ae90..fc946658a 100644 --- a/mods/src/settings/shortcut_settings.cc +++ b/mods/src/settings/shortcut_settings.cc @@ -58,6 +58,10 @@ namespace return "Mod shortcut hooks are disabled in TOML."; if (Config::Get().use_scopely_hotkeys) return "Native shortcut mode is active. Disable it and restart to edit mod shortcuts."; + if (!MapKey::Definition(action).enabled) + return !Config::Get().hotkeys_extended + ? "Extended shortcuts are disabled. Enable them and restart." + : "Experimental shortcuts are disabled. Enable control.enable_experimental in TOML and restart."; if (!captureReady) return "Shortcut recording is unavailable for this client."; if (action == GameFunction::ToggleShortcutHints && !ShortcutHintControlAvailable()) diff --git a/mods/src/settings/upstream_settings.cc b/mods/src/settings/upstream_settings.cc index 8a6cf1d62..f75caab5b 100644 --- a/mods/src/settings/upstream_settings.cc +++ b/mods/src/settings/upstream_settings.cc @@ -105,8 +105,9 @@ namespace }}; if (timing == Timing::Restart) definition.active_value = [read]() -> std::optional { return read(); }; - auto result = PersistedDefinition(std::move(definition), {section, key, help, unit, timing}, - std::move(defaultValue), std::move(serializer)); + auto result = PersistedDefinition( + std::move(definition), {section, key, help, unit, timing}, std::move(defaultValue), std::move(serializer), + std::is_floating_point_v ? std::chrono::milliseconds(250) : std::chrono::milliseconds{}); currentSavers.insert_or_assign(result.metadata.TomlKey(), [read, available, persist = result.persist] { if (!available() || !persist) return false; @@ -452,7 +453,8 @@ void RegisterUpstreamSettings(PageCatalog& catalog) BOOL("controls", "control", "hotkeys_enabled", "Enable mod shortcuts", hotkeys_enabled, C::hotkeys_enabled, hotkeys, Live, "Use the mod's configured keyboard and mouse shortcuts."); BOOL("controls", "control", "hotkeys_extended", "Extended shortcut actions", hotkeys_extended, C::hotkeys_extended, - hotkeys, Live, "Enable the extended set of mod shortcut actions."); + hotkeys, Restart, + "Enable the extended set of mod shortcut actions. Restart to change their startup availability."); BOOL("controls", "control", "use_scopely_hotkeys", "Use native game shortcuts", use_scopely_hotkeys, C::use_scopely_hotkeys, hotkeys, Restart, "The game uses its own shortcuts instead of the mod's shortcut bindings."); @@ -786,10 +788,10 @@ void RegisterUpstreamSettings(PageCatalog& catalog) "them."; }); Information(catalog, "community_mod.advanced", "community_mod.configuration.experimental", - "Legacy experimental options", [] { - return "control.enable_experimental and graphics.system_pan_momentum remain compatibility inputs in " - "TOML. The current mod does not use them to change gameplay. Pan glide is controlled by " - "system_pan_momentum_falloff."; + "Experimental shortcut options", [] { + return "control.enable_experimental controls startup availability of experimental shortcuts. " + "Enable it in TOML and restart. graphics.system_pan_momentum is a legacy input with no " + "current gameplay consumer; pan glide uses system_pan_momentum_falloff."; }); #if !defined(_WIN32) Information(catalog, "community_mod.advanced", "community_mod.configuration.windows", "Windows window options", [] { diff --git a/tests/config_save_failure_test.cc b/tests/config_save_failure_test.cc index 8bc083c3c..f1c2326c0 100644 --- a/tests/config_save_failure_test.cc +++ b/tests/config_save_failure_test.cc @@ -49,6 +49,9 @@ int main(int argc, char** argv) failed = true; } assert(failed); + std::string startupError; + assert(!SaveStartupConfigDocument(toml::table{{"enabled", true}}, path, {}, startupError)); + assert(!startupError.empty()); // Startup continues with the loaded live value. std::ifstream input(path, std::ios::binary); const std::string actual(std::istreambuf_iterator{input}, {}); assert(actual == original); diff --git a/tests/config_save_test.cc b/tests/config_save_test.cc index c34c7c409..48cce7219 100644 --- a/tests/config_save_test.cc +++ b/tests/config_save_test.cc @@ -53,9 +53,14 @@ int main(int argc, char** argv) } catch (const std::exception&) { failed = true; } + std::string startupError; + assert(!SaveStartupConfigDocument(config, path, {}, startupError)); + assert(!startupError.empty()); CloseHandle(handle); assert(failed); assert(toml::parse_file(path.string())["enabled"].value() == true); + assert(SaveStartupConfigDocument(config, path, {}, startupError) && startupError.empty()); + assert(toml::parse_file(path.string())["enabled"].value() == false); #else const auto mode = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write; std::filesystem::permissions(path, mode); diff --git a/tests/input_release_test.cc b/tests/input_release_test.cc new file mode 100644 index 000000000..4d9194fe1 --- /dev/null +++ b/tests/input_release_test.cc @@ -0,0 +1,20 @@ +#include "settings/input_release.h" +#include +#include +int main() +{ + mod_settings::InputRelease release; + assert(release.Tick(true, false)); + release.Hold(); // Typing/search focus owns input. + for (int i = 0; i < 10; ++i) + assert(!release.Tick(true, true)); // Blur/result navigation must still drain Q, W or mouse. + assert(!release.Tick(false, false)); // Focus loss cannot prove key release. + assert(!release.Tick(true, false)); + assert(!release.Tick(true, true)); // A held/repressed key resets the quiet frame. + assert(!release.Tick(true, false)); + assert(release.Tick(true, false)); + release.Hold(); // Reopening takes ownership again without returning keys to gameplay. + assert(release.active() && !release.Tick(true, false)); + assert(release.Tick(true, false)); + std::cout << "Input ownership survives blur/teardown/focus loss until supported keys release\n"; +} diff --git a/tests/run-settings.ps1 b/tests/run-settings.ps1 index edc901d8f..0780b90cb 100644 --- a/tests/run-settings.ps1 +++ b/tests/run-settings.ps1 @@ -8,13 +8,13 @@ $fixtureRoot = Join-Path $testRoot ([guid]::NewGuid().ToString()) function Invoke-SettingsFixture { param([string]$Name, [string[]]$Sources, [string[]]$TestArguments = @(), - [string[]]$IncludeDirectories = @()) + [string[]]$IncludeDirectories = @(), [string[]]$LinkArguments = @()) $executable = Join-Path $testRoot ($Name + '.exe') $compilerArguments = @('--driver-mode=cl', '/std:c++latest', '/EHsc', '/MT', '/DNOMINMAX') $compilerArguments += @($IncludeDirectories | ForEach-Object { "/I$_" }) $compilerArguments += @('/Imods/src', '/Itests', '/Ithird_party/libil2cpp', "/I$TomlInclude", "/Fe$executable", "/Fo$testRoot/", '-Wno-deprecated-literal-operator') - & clang++ @compilerArguments @Sources + & clang++ @compilerArguments @Sources @LinkArguments if ($LASTEXITCODE -ne 0) { throw "Settings fixture compilation failed: $Name" } & $executable @TestArguments if ($LASTEXITCODE -ne 0) { throw "Settings fixture failed: $Name" } @@ -58,7 +58,7 @@ try { New-Item -ItemType Directory -Force $fixtureRoot | Out-Null foreach ($fixture in @('boolean_settings', 'boolean_view', 'native_boolean_callback', 'page_catalog', 'page_sections', 'settings_search', 'choice_setting', 'slider_setting', 'shortcut_editor', - 'settings_metadata', 'persisted_setting', 'settings_maintenance')) { + 'settings_metadata', 'persisted_setting', 'settings_maintenance', 'input_release')) { Invoke-SettingsFixture -Name $fixture -Sources @("tests/${fixture}_test.cc") } Invoke-SettingsFixture -Name 'upstream_settings' -Sources @('tests/upstream_settings_test.cc', @@ -67,6 +67,14 @@ try { -TestArguments @('docs/settings-coverage.json', (Join-Path $fixtureRoot 'catalog')) Invoke-SettingsFixture -Name 'runtime_config' -Sources @('tests/runtime_config_test.cc') Invoke-SettingsFixture -Name 'runtime_config_writer' -Sources @('tests/runtime_config_writer_test.cc') + $packageIncludes = @('s/simdutf', 'e/eastl') | ForEach-Object { + $package = Join-Path $env:LOCALAPPDATA ('.xmake/packages/' + $_) + Get-ChildItem -LiteralPath $package -Directory -Recurse | + Where-Object Name -eq 'include' | Select-Object -First 1 -ExpandProperty FullName + } + Invoke-SettingsFixture -Name 'shortcut_options' -Sources @('tests/shortcut_hint_cache.cc', + 'mods/src/patches/mapkey.cc', 'mods/src/patches/modifierkey.cc') -IncludeDirectories $packageIncludes ` + -LinkArguments @('/link', '/NODEFAULTLIB:GameAssembly.lib') Invoke-SettingsFixture -Name 'config_save' -Sources @('tests/config_save_test.cc', 'mods/src/config_save.cc') ` -TestArguments @(Join-Path $fixtureRoot 'created') Invoke-SettingsFixture -Name 'config_save_failure' -Sources @('tests/config_save_failure_test.cc') ` diff --git a/tests/runtime_config_fixture.h b/tests/runtime_config_fixture.h index e6f97631d..640eb27a8 100644 --- a/tests/runtime_config_fixture.h +++ b/tests/runtime_config_fixture.h @@ -61,6 +61,13 @@ struct RuntimeConfigWriter { } bool PollStopped() const { return finished; } + bool Resume() + { + if (!stopped || !finished || cancelled) + return false; + stopped = finished = false; + return true; + } void* NativeHandle() const { return handle; } unsigned Submit(const char*) diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc index 43fc16677..8e625ac3e 100644 --- a/tests/runtime_config_test.cc +++ b/tests/runtime_config_test.cc @@ -127,6 +127,9 @@ int main(int argc, char** argv) Update(); Update(); assert(resumes == 1); + runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("warp"), {}); + assert(fixture.submissions == 1 && !stopped && !draining && !fixture.stopped); + assert(WantsQuit([] { return true; })); // A later explicit quit still works. Reset(); fixture.work = true; @@ -138,6 +141,9 @@ int main(int argc, char** argv) Update(); assert(resumes == 0); + runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("jump"), {}); + assert(fixture.submissions == 1 && !draining && !fixture.stopped); + Reset(); assert(!WantsQuit([] { return false; })); runtime_config::SaveSetting("ui", "auto_confirm_instant_warp", std::string("warp"), {}); diff --git a/tests/runtime_config_writer_test.cc b/tests/runtime_config_writer_test.cc index 2b11d4eac..eece5d429 100644 --- a/tests/runtime_config_writer_test.cc +++ b/tests/runtime_config_writer_test.cc @@ -269,6 +269,31 @@ int main() } Check(requests.empty()); } + Begin(Outcome::Saved); + { + RuntimeConfigWriter writer("unused"); + Check(writer.Register("ui", "mode", Value{std::string("none")})); + const auto first = writer.Submit("ui", "mode", std::string("warp")); + AwaitSave(); + Release(); + AwaitCompletion(writer, first); + writer.Stop(false); + const auto deadline = std::chrono::steady_clock::now() + 5s; + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + Check(writer.Resume() && !writer.Resume()); + const auto second = writer.Submit("ui", "mode", std::string("jump")); + AwaitCompletion(writer, second); + Check(second > first && writer.GetStatus("ui", "mode").outcome == Outcome::Saved); + Check(requests.size() == 2 && requests.back().expected == std::optional{std::string("warp")}); + writer.Stop(false); + while (!writer.PollStopped()) { + Check(std::chrono::steady_clock::now() < deadline); + std::this_thread::yield(); + } + } RuntimeConfigWriter idle("unused"); idle.Stop(false); Check(idle.PollStopped()); diff --git a/tests/settings_metadata_test.cc b/tests/settings_metadata_test.cc index 1ca3b99e7..f2726a710 100644 --- a/tests/settings_metadata_test.cc +++ b/tests/settings_metadata_test.cc @@ -1,3 +1,4 @@ +#include "settings/native_view_state.h" #include "settings/value_settings.h" #include #include @@ -77,5 +78,23 @@ int main() ValueSetting second(std::move(throwing)); assert(second.SetFromUser(true, second.Observe()).outcome == Outcome::AppliedVerified); assert(second.save_status().state == SaveState::Unavailable); + int selected = 0; + SaveStatus choiceDisk{SaveState::Pending, 1}; + ValueDefinition choiceDefinition{"choice", "Mode", [&] { return ValueReadResult::Known(selected, 1); }, + [&](int desired, std::uint64_t) { + selected = desired; + return ApplyResult::Applied; + }}; + choiceDefinition.save_status = [&] { return choiceDisk; }; + ChoiceSetting choice(std::move(choiceDefinition), {"First", "Second"}); + NativeViewState styled(choice, 0); + styled.Bind(); + for (const auto status : + {SaveState::Pending, SaveState::Saved, SaveState::Conflict, SaveState::Failed, SaveState::Unavailable}) { + choiceDisk.state = status; + const auto label = styled.presentation_label(); + assert(label.find(choice.state().status_text()) != std::string::npos); + assert(label.starts_with("First\n")); // Styled and ordinary adapters use this same text. + } std::cout << "Setting metadata/reset/retry/persistence-readback fixtures passed\n"; } diff --git a/tests/shortcut_hint_cache.cc b/tests/shortcut_hint_cache.cc index 1b82ce68a..261d0bdec 100644 --- a/tests/shortcut_hint_cache.cc +++ b/tests/shortcut_hint_cache.cc @@ -1,7 +1,8 @@ // Link against the built mods library. Only Unity-dependent Key operations are stubbed; // MapKey parsing, ModifierKey parsing, binding detection, and hint caching are production code. -#include "patches/mapkey.h" #include "defaultconfig.h" +#include "patches/mapkey.h" +#include "patches/optional_shortcuts.h" #include #include @@ -130,6 +131,31 @@ int main() "Overlap advice differs from the existing physical minimum-modifier matcher"); Check(MapKey::ReplaceBindings(galaxy, {}) && !MapKey::HasBinding(galaxy) && MapKey::GetShortcutHint(galaxy).empty(), "Removing all alternatives did not unbind the action"); + // Exercise the exact optional startup catalog in all flag combinations. + for (const bool extended : {false, true}) + for (const bool experimental : {false, true}) { + held.fill(false); + held[static_cast(KeyCode::F7)] = true; + unsigned count = 0; + shortcut_options::LoadAll(extended, experimental, [&](const auto& definition, bool enabled) { + ++count; + MapKey::RegisterAction(definition.action, definition.key, definition.default_binding); + Check(MapKey::ReplaceBindings(definition.action, {MapKey::Parse("F7")}), "startup binding load failed"); + MapKey::SetActionEnabled(definition.action, enabled); + Check(MapKey::Definition(definition.action).key == definition.key + && MapKey::Bindings(definition.action).size() == 1, + "inactive shortcut metadata/bindings disappeared"); + Check(enabled == (extended && (!definition.experimental || experimental)), + "startup flags misclassified action"); + Check(MapKey::IsDown(definition.action) == enabled && MapKey::IsPressed(definition.action) == enabled, + "disabled optional action reached dispatch"); + if (!enabled) { + Check(!MapKey::ReplaceBindings(definition.action, {}), "inactive action admitted editor mutation"); + Check(MapKey::GetShortcutHint(definition.action).empty(), "inactive action advertised a working hint"); + } + }); + Check(count == 18, "optional shortcut catalog coverage drift"); + } std::cout << "Shortcut hint and movement binding tests passed\n"; } diff --git a/tests/test_settings_coverage.py b/tests/test_settings_coverage.py index 62e808952..6cd534520 100644 --- a/tests/test_settings_coverage.py +++ b/tests/test_settings_coverage.py @@ -77,7 +77,7 @@ def test_every_in_game_key_has_a_definition(self): self.assertIn(row["key"].split(".", 1)[1], literals) self.assertTrue(row["control"]) self.assertTrue(row["page"]) - self.assertIn("PersistedDefinition(std::move(definition)", self.catalog) + self.assertRegex(self.catalog, r"PersistedDefinition\(\s*std::move\(definition\)") self.assertNotIn("runtime_config::SaveSetting", self.catalog, "field callbacks must use their definition's serializer") self.assertRegex(self.catalog, r"action->metadata\s*=\s*&setting->metadata\(\)") @@ -87,6 +87,7 @@ def test_all_registered_shortcuts_are_included(self): keys = re.findall( r'parse_config_shortcut(?:_aliases)?\(config,\s*parsed,\s*"([^\"]+)"', source("mods/src/config.cc")) + keys += re.findall(r'GameFunction::\w+,\s*"([^\"]+)"', source("mods/src/patches/optional_shortcuts.h")) expected = {"shortcuts." + key for key in keys} self.assertEqual(len(keys), len(expected), "duplicate canonical shortcut registration") self.assertEqual(expected, set(self.inventory["shortcuts"])) @@ -115,8 +116,8 @@ def test_only_upstream_feature_dependencies_are_included(self): "keyboard_layout", "ship_shortcut_badges"): self.assertNotIn(f'#include "{forbidden}', self.catalog) self.assertNotIn(f'#include "patches/{forbidden}', self.catalog) - for key in ("control.enable_experimental", "graphics.system_pan_momentum"): - self.assertEqual(self.rows[key]["policy"], "legacy_experimental") + self.assertEqual(self.rows["control.enable_experimental"]["policy"], "experimental_shortcuts") + self.assertEqual(self.rows["graphics.system_pan_momentum"]["policy"], "legacy_experimental") if __name__ == "__main__": diff --git a/tests/upstream_settings_test.cc b/tests/upstream_settings_test.cc index c016a7085..abe9212c6 100644 --- a/tests/upstream_settings_test.cc +++ b/tests/upstream_settings_test.cc @@ -29,6 +29,7 @@ using Value = config_edit::Value; struct Write { std::string key; Value value; + std::chrono::milliseconds delay{}; }; std::vector writes; std::set registered; @@ -491,8 +492,9 @@ bool RegisterSetting(const char* section, const char* key) noexcept fixture::registered.insert(std::string(section) + "." + key); return true; } -void SaveSetting(const char* section, const char* key, config_edit::Value value, std::chrono::milliseconds) noexcept -{ fixture::writes.push_back({std::string(section) + "." + key, std::move(value)}); } +void SaveSetting(const char* section, const char* key, config_edit::Value value, + std::chrono::milliseconds delay) noexcept +{ fixture::writes.push_back({std::string(section) + "." + key, std::move(value), delay}); } mod_settings::SaveStatus GetSaveStatus(const char*, const char*) noexcept { return {mod_settings::SaveState::Saved, fixture::writes.size()}; } } // namespace runtime_config @@ -704,6 +706,8 @@ int main(int argc, char** argv) model.reset(); Require(writes.size() == 2 && writes.back().key == key, "reset did not queue exactly its own field: " + key); Require(writes.back().value == defaults.at(key), "reset/default serialization mismatch: " + key); + if (key == "graphics.ui_scale" || key == "graphics.zoom_label_player_threshold") + Require(writes.back().delay == std::chrono::milliseconds(250), "float edits lost save debounce: " + key); Require(ReadAll(reads) == defaults, "reset did not restore native baseline: " + key); } Require(shipRefreshes == 2 && previousShipScale != nextShipScale, "ship scale did not refresh loaded ships"); From 1f1933232583ff02adc8acdc8f747353880c1955 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 04:01:04 -0500 Subject: [PATCH 03/17] Retain settings search across focus loss and classify extended zoom actions --- docs/MOD_SETTINGS.md | 2 +- mods/src/config.cc | 3 --- mods/src/patches/optional_shortcuts.h | 6 +++++- mods/src/settings/native/settings_search.cc | 10 ++++++---- tests/shortcut_hint_cache.cc | 2 +- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md index 80286d082..6a03f95aa 100644 --- a/docs/MOD_SETTINGS.md +++ b/docs/MOD_SETTINGS.md @@ -63,7 +63,7 @@ For each candidate artifact, record its source commit, client build, platform an Use reversible preferences for these player checks; no claim or purchase is required: -1. Open Mod Settings, visit each category, search a label and a TOML key, follow a result, and use Back. Close and reopen Settings; verify the game's own pages still work. +1. Open Mod Settings, visit each category, search a label and a TOML key, follow a result, and use Back. Type a query, Alt-Tab away and back, then edit and clear the query on the same page. Close and reopen Settings; verify the game's own pages still work. 2. Toggle a live option, reopen its page, then restart the client and check the saved value. Confirm native Fleet Commander preferences still use their existing game storage. 3. Enter an exact number, an invalid number, and a valid value outside its convenience slider range. Cancel an edit and verify no change or save occurred. Check interface scale at the smallest supported window size. 4. Change a restart preference and verify it shows pending restart while the active behavior stays unchanged. Restore it afterward. diff --git a/mods/src/config.cc b/mods/src/config.cc index d32f672fe..77e105946 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1437,9 +1437,6 @@ void Config::Load() parse_config_shortcut(config, parsed, "zoom_preset5", GameFunction::ZoomPreset5, DCSH::zoom_preset5); parse_config_shortcut(config, parsed, "zoom_in", GameFunction::ZoomIn, DCSH::zoom_in); parse_config_shortcut(config, parsed, "zoom_out", GameFunction::ZoomOut, DCSH::zoom_out); - parse_config_shortcut(config, parsed, "zoom_max", GameFunction::ZoomMax, DCSH::zoom_max); - parse_config_shortcut(config, parsed, "zoom_min", GameFunction::ZoomMin, DCSH::zoom_min); - parse_config_shortcut(config, parsed, "zoom_reset", GameFunction::ZoomReset, DCSH::zoom_reset); parse_config_shortcut(config, parsed, "ui_scaleup", GameFunction::UiScaleUp, DCSH::ui_scaleup); parse_config_shortcut(config, parsed, "ui_scaledown", GameFunction::UiScaleDown, DCSH::ui_scaledown); parse_config_shortcut(config, parsed, "ui_scaleshipup", GameFunction::UiShipScaleUp, DCSH::ui_scaleshipup); diff --git a/mods/src/patches/optional_shortcuts.h b/mods/src/patches/optional_shortcuts.h index e6ea5a057..eb0b17a55 100644 --- a/mods/src/patches/optional_shortcuts.h +++ b/mods/src/patches/optional_shortcuts.h @@ -12,6 +12,9 @@ struct Definition { bool experimental; }; inline constexpr auto definitions = std::to_array({ + {GameFunction::ZoomMax, "zoom_max", DefaultConfig::Shortcuts::zoom_max, false}, + {GameFunction::ZoomMin, "zoom_min", DefaultConfig::Shortcuts::zoom_min, false}, + {GameFunction::ZoomReset, "zoom_reset", DefaultConfig::Shortcuts::zoom_reset, false}, {GameFunction::ShowAlliance, "show_alliance", DefaultConfig::Shortcuts::show_alliance, false}, {GameFunction::ShowAllianceHelp, "show_alliance_help", DefaultConfig::Shortcuts::show_alliance_help, true}, {GameFunction::ShowAllianceArmada, "show_alliance_armada", DefaultConfig::Shortcuts::show_alliance_armada, true}, @@ -33,7 +36,8 @@ inline constexpr auto definitions = std::to_array({ {GameFunction::ToggleCargoHostile, "toggle_cargo_hostile", DefaultConfig::Shortcuts::toggle_cargo_hostile, false}, {GameFunction::ToggleCargoArmada, "toggle_cargo_armada", DefaultConfig::Shortcuts::toggle_cargo_armada, false}, }); -// Always load metadata and configured bindings; dispatch retains its startup +// Includes both conditionally loaded actions and conditionally dispatched zoom +// actions. Always load metadata and configured bindings; dispatch retains its startup // availability. Disabled actions remain discoverable with a reason in Settings. template void LoadAll(bool extended, bool experimental, Load load) { diff --git a/mods/src/settings/native/settings_search.cc b/mods/src/settings/native/settings_search.cc index 2e15439a9..057b5ea2b 100644 --- a/mods/src/settings/native/settings_search.cc +++ b/mods/src/settings/native/settings_search.cc @@ -112,15 +112,17 @@ namespace } return; } - if (!focused || !focused()) { - CloseSettingsSearch(); - return; - } if (!Alive(Target(object)) || !Alive(Target(owner)) || !Boolean(UiCall(Target(object), "get_activeInHierarchy"))) { CloseSettingsSearch(); return; } + if (!focused || !focused()) { + // Alt-Tab suspends input ownership; it does not end the page visit. + // Retain the field/query so returning focus can edit or clear them. + release.Tick(false, false); + return; + } const bool editing = Boolean(UiCall(Target(input), "get_isFocused")); if (editing) { if (!backHeld) { diff --git a/tests/shortcut_hint_cache.cc b/tests/shortcut_hint_cache.cc index 261d0bdec..eae4cbbb9 100644 --- a/tests/shortcut_hint_cache.cc +++ b/tests/shortcut_hint_cache.cc @@ -154,7 +154,7 @@ int main() Check(MapKey::GetShortcutHint(definition.action).empty(), "inactive action advertised a working hint"); } }); - Check(count == 18, "optional shortcut catalog coverage drift"); + Check(count == 21, "optional shortcut catalog coverage drift"); } std::cout << "Shortcut hint and movement binding tests passed\n"; From 3a26e8ca804937ab545e5a40f695e4460253455e Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 04:08:46 -0500 Subject: [PATCH 04/17] Parse settings decimals on supported macOS deployment targets --- mods/src/settings/upstream_settings.cc | 29 +++++++++++++++++++++----- tests/upstream_settings_test.cc | 22 ++++++++++++++++++- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/mods/src/settings/upstream_settings.cc b/mods/src/settings/upstream_settings.cc index f75caab5b..4574e1dbc 100644 --- a/mods/src/settings/upstream_settings.cc +++ b/mods/src/settings/upstream_settings.cc @@ -16,8 +16,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -72,13 +74,30 @@ namespace text = Trim(text); if (text.empty()) return {}; - T value{}; - const auto result = std::from_chars(text.data(), text.data() + text.size(), value); - if (result.ec != std::errc{} || result.ptr != text.data() + text.size() || value < minimum || value > maximum) - return {}; - if constexpr (std::is_floating_point_v) + T value{}; + if constexpr (std::is_floating_point_v) { + // Apple's floating from_chars requires macOS 26. Use the classic locale + // on every platform while retaining full-token and range validation. + if (text.front() == '+' || text.find_first_not_of("0123456789.eE+-") != text.npos) + return {}; + std::istringstream input{std::string(text)}; + input.imbue(std::locale::classic()); + input >> std::noskipws >> value; + if (!input || input.peek() != std::char_traits::eof()) + return {}; if (!std::isfinite(value)) return {}; + // Some stream libraries silently round underflow to zero. An explicit + // zero significand is valid; a nonzero value lost to underflow is not. + if (value == 0 && text.substr(0, text.find_first_of("eE")).find_first_not_of("-0.") != text.npos) + return {}; + } else { + const auto result = std::from_chars(text.data(), text.data() + text.size(), value); + if (result.ec != std::errc{} || result.ptr != text.data() + text.size()) + return {}; + } + if (value < minimum || value > maximum) + return {}; return value; } diff --git a/tests/upstream_settings_test.cc b/tests/upstream_settings_test.cc index abe9212c6..15f606e10 100644 --- a/tests/upstream_settings_test.cc +++ b/tests/upstream_settings_test.cc @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -728,8 +729,9 @@ int main(int argc, char** argv) Require(writes.empty() && ReadAll(reads) == defaults, "invalid input mutated state"); modal.reset(); }; - for (const auto* text : {"", "nan", "inf", "5garbage", "-1"}) + for (const auto* text : {"", "nan", "inf", "5garbage", "-1", "+5", "0x1p2", "1e1000", "1e-1000", "4 75"}) invalid("graphics.keyboard_zoom_speed", text); + invalid("graphics.system_pan_momentum_falloff", "1e-1000"); invalid("control.select_timer", "1.5"); invalid("control.select_timer", "2147483648"); invalid("ui.cargo_significant_decimals", "7"); @@ -738,6 +740,24 @@ int main(int argc, char** argv) invalid("ui.disabled_banner_types", "not-a-banner"); invalid("graphics.loader_image", (directory / "missing.png").string()); + // Exact numeric entry keeps dot/scientific syntax even when the user's + // global C++ locale uses a comma decimal separator. + struct CommaDecimal : std::numpunct { + char do_decimal_point() const override + { return ','; } + }; + const auto previousLocale = std::locale::global(std::locale(std::locale::classic(), new CommaDecimal)); + auto* numeric = models.at("graphics.keyboard_zoom_speed").exact; + numeric->invoke(0); + Require(modal && modal->validate(" 4.75e2 ").empty() && !modal->validate("4,75e2").empty(), + "float entry depends on the process locale"); + Require(modal->commit(" 4.75e2 ") && Config::Get().keyboard_zoom_speed == 475, + "dot/scientific float did not reach the native value"); + modal.reset(); + models.at("graphics.keyboard_zoom_speed").reset(); + std::locale::global(previousLocale); + Require(ReadAll(reads) == defaults, "numeric locale test did not restore the baseline"); + // A stale exact editor and a stale prepared reset cannot overwrite a later // native shortcut mutation. No worker/config-file behavior is simulated. auto* speed = models.at("graphics.keyboard_zoom_speed").exact; From 3d43f129a753ad298b5d2ca9d29895ae4e41c91c Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 04:40:17 -0500 Subject: [PATCH 05/17] Correct settings persistence feedback and catalog capabilities --- docs/MOD_SETTINGS.md | 8 +- docs/settings-coverage.json | 14 +- example_community_patch_settings_en.toml | 2 + mods/src/config.cc | 123 ------------ mods/src/config_filters.cc | 129 ++++++++++++ mods/src/patches/parts/runtime_config.cc | 18 +- mods/src/patches/parts/zoom.cc | 17 +- mods/src/patches/runtime_config.h | 3 +- mods/src/runtime_config_writer.cc | 10 +- mods/src/runtime_config_writer.h | 1 + mods/src/settings/boolean_settings.h | 40 ---- mods/src/settings/maintenance.h | 8 +- mods/src/settings/persisted_setting.h | 6 +- mods/src/settings/setting_metadata.h | 4 +- mods/src/settings/upstream_settings.cc | 83 +++++--- mods/src/settings/value_settings.h | 22 ++- mods/src/settings/warp_mode.cc | 6 - mods/src/settings/warp_mode.h | 5 +- mods/src/str_utils.h | 8 +- tests/boolean_settings_test.cc | 13 +- tests/persisted_setting_test.cc | 25 ++- tests/run-settings.ps1 | 14 +- tests/runtime_config_fixture.h | 11 +- tests/runtime_config_test.cc | 19 +- tests/runtime_config_writer_test.cc | 5 + tests/settings_maintenance_test.cc | 8 +- tests/settings_metadata_test.cc | 10 +- .../upstream_settings_fixture/spdlog/spdlog.h | 1 + tests/upstream_settings_test.cc | 184 +++++++++++------- 29 files changed, 452 insertions(+), 345 deletions(-) create mode 100644 mods/src/config_filters.cc diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md index 6a03f95aa..a2e075ce0 100644 --- a/docs/MOD_SETTINGS.md +++ b/docs/MOD_SETTINGS.md @@ -8,6 +8,8 @@ The editor uses the same configuration values and defaults as the mod's TOML con Ordinary controls apply to the current session and save automatically. Each control reports its own save state. A failed save or an external edit conflict remains visible; another successful save does not clear it. Resolve a conflicting external edit before retrying. Unrelated TOML values and comments are preserved. +Save feedback describes the current value. A later session-only change cannot retain an older Saved badge. Explicit save-preset/default shortcuts persist their zoom values; automatically using the last preset as the session default remains session-only. Defaults & Help offers Save current value for an unsaved session change. + Slider and scale-shortcut changes apply immediately and save after 250 ms without another change to that value. Orderly exit flushes pending changes. If the game vetoes a quit request, saving resumes for the continuing session. A failure to write the generated runtime-vars diagnostic does not prevent startup with a readable configuration. Some controls take effect when their screen is reopened. Restart controls retain the requested preference separately from the active value, so a saved restart change is not presented as already active. Examples include native-game shortcut mode, window setup, loading assets, cargo formatting, officer sort initialization, and chat channel suppression. @@ -41,15 +43,15 @@ These categories are explained in the Advanced page rather than exposed as ordin | `audio.trace_events`, `audio.disabled_events` | Diagnostic event tracing and arbitrary internal event-name filters remain advanced configuration. | | `[config]` endpoint overrides | Configuration/distribution infrastructure is configured before the in-game editor starts. Restart after editing. | | `control.enable_experimental` | Enables experimental shortcuts at startup. Configure it in TOML and restart. Disabled actions remain visible with a reason. | -| `graphics.system_pan_momentum` | Legacy parser input with no current gameplay consumer. The functional pan-glide option is `graphics.system_pan_momentum_falloff`. | +| `graphics.system_pan_momentum`, `graphics.transition_time`, `ui.disable_toast_banners` | Legacy parser inputs with no current gameplay consumer. Pan glide uses `graphics.system_pan_momentum_falloff`; banner suppression uses `ui.disabled_banner_types`. | -`graphics.free_resize` and `graphics.borderless_fullscreen` are Windows options. The macOS editor explains their platform scope instead of offering controls that cannot affect that client. +`graphics.free_resize`, `graphics.borderless_fullscreen`, `graphics.allow_cursor`, `ui.instant_cargo_counter` and `ui.notify_banner_types` have Windows-only consumers. The macOS editor explains their platform scope instead of offering controls that cannot affect that client. See the English example configuration for accepted advanced values. Deprecated aliases remain startup compatibility inputs; the editor writes canonical keys. Old queue feature switches and constants that the parser no longer reads are not extra controls. ## Coverage and maintenance -[settings-coverage.json](settings-coverage.json) inventories all 154 canonical configuration keys and 105 canonical shortcut actions. The feature includes 89 player-setting controls. The remaining 26 named non-hook options and 39 hook-install switches have explicit TOML-only treatment. Dynamic sync target fields follow the same sync policy. The emergency switches `patches.nativesettingshooks` and `patches.runtimeconfighooks` independently control native settings and the shared persistence runtime. +[settings-coverage.json](settings-coverage.json) inventories all 154 canonical configuration keys and 105 canonical shortcut actions. The feature includes 87 player-setting controls on Windows and 82 on macOS. The remaining 28 named non-hook options and 39 hook-install switches have explicit TOML-only treatment. Dynamic sync target fields follow the same sync policy. The emergency switches `patches.nativesettingshooks` and `patches.runtimeconfighooks` independently control native settings and the shared persistence runtime. `settings/upstream_settings.cc` defines controls with their canonical key, default, help, unit, application timing, availability, validation, and persistence serializer together. Shared instant-warp and Forbidden Tech models retain their own definitions so native settings and the mod page use the same state. Live shortcut and pin actions reuse the same field serializer through `PersistCurrentSetting`; they do not maintain a second save whitelist. A new option should receive either an in-game definition or an explicit inventory exclusion. A new shortcut should appear through the registered action catalog. diff --git a/docs/settings-coverage.json b/docs/settings-coverage.json index ea7ae10b6..3680286c7 100644 --- a/docs/settings-coverage.json +++ b/docs/settings-coverage.json @@ -10,7 +10,7 @@ {"key": "audio.alert_victory", "control": "choice", "page": "notifications", "application": "live", "treatment": "in_game"}, {"key": "audio.disabled_events", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "audio"}, {"key": "audio.trace_events", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "audio"}, - {"key": "buffs.use_out_of_dock_power", "control": "boolean", "page": "officers", "application": "live", "treatment": "in_game"}, + {"key": "buffs.use_out_of_dock_power", "control": "boolean", "page": "officers", "application": "restart", "treatment": "in_game"}, {"key": "config.assets_url_override", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "config"}, {"key": "config.settings_url", "treatment": "toml_only", "page": "advanced", "application": "restart", "policy": "config"}, {"key": "control.enable_experimental", "treatment": "toml_only", "application": "restart", "page": "advanced", "policy": "experimental_shortcuts"}, @@ -21,7 +21,7 @@ {"key": "control.queue_enabled", "control": "boolean", "page": "controls", "application": "live", "treatment": "in_game"}, {"key": "control.select_timer", "control": "number", "page": "controls", "application": "live", "treatment": "in_game"}, {"key": "control.use_scopely_hotkeys", "control": "boolean", "page": "controls", "application": "restart", "treatment": "in_game"}, - {"key": "graphics.allow_cursor", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, + {"key": "graphics.allow_cursor", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game", "platforms": ["windows"], "other_platforms": "Explanatory advanced row; this setting has no macOS consumer."}, {"key": "graphics.borderless_fullscreen", "control": "boolean", "page": "interface", "application": "restart", "treatment": "in_game", "platforms": ["windows"], "other_platforms": "Explanatory advanced row; native Windows-only functionality is not offered on macOS."}, {"key": "graphics.default_system_zoom", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, {"key": "graphics.fr_scale", "control": "number", "page": "interface", "application": "restart", "treatment": "in_game"}, @@ -42,7 +42,7 @@ {"key": "graphics.system_zoom_preset_3", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, {"key": "graphics.system_zoom_preset_4", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, {"key": "graphics.system_zoom_preset_5", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, - {"key": "graphics.transition_time", "control": "number", "page": "camera", "application": "live", "treatment": "in_game"}, + {"key": "graphics.transition_time", "treatment": "toml_only", "application": "restart", "page": "advanced", "policy": "legacy_experimental"}, {"key": "graphics.ui_scale", "control": "number", "page": "interface", "application": "live", "treatment": "in_game"}, {"key": "graphics.ui_scale_ship", "control": "number", "page": "interface", "application": "live", "treatment": "in_game"}, {"key": "graphics.ui_scale_step", "control": "number", "page": "interface", "application": "live", "treatment": "in_game"}, @@ -128,12 +128,12 @@ {"key": "ui.disable_move_keys", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, {"key": "ui.disable_preview_locate", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, {"key": "ui.disable_preview_recall", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, - {"key": "ui.disable_toast_banners", "control": "boolean", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "ui.disable_toast_banners", "treatment": "toml_only", "application": "restart", "page": "advanced", "policy": "legacy_experimental"}, {"key": "ui.disable_veil_chat", "control": "boolean", "page": "chat", "application": "restart", "treatment": "in_game"}, {"key": "ui.disabled_banner_types", "control": "list", "page": "notifications", "application": "live", "treatment": "in_game"}, {"key": "ui.dock_syncmanagement", "control": "boolean", "page": "officers", "application": "live", "treatment": "in_game"}, {"key": "ui.double_click_to_assign_ship", "control": "boolean", "page": "officers", "application": "live", "treatment": "in_game"}, - {"key": "ui.extend_chest_purchase_max", "control": "number", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.extend_chest_purchase_max", "control": "number", "page": "claims", "application": "restart", "treatment": "in_game"}, {"key": "ui.extend_donation_max", "control": "number", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, {"key": "ui.extend_donation_slider", "control": "boolean", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, {"key": "ui.focus_search", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, @@ -142,11 +142,11 @@ {"key": "ui.hud_missions", "control": "choice", "page": "hud", "application": "live", "treatment": "in_game"}, {"key": "ui.hud_outposts", "control": "choice", "page": "hud", "application": "live", "treatment": "in_game"}, {"key": "ui.hud_q_trials", "control": "choice", "page": "hud", "application": "live", "treatment": "in_game"}, - {"key": "ui.instant_cargo_counter", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game"}, + {"key": "ui.instant_cargo_counter", "control": "boolean", "page": "previews", "application": "live", "treatment": "in_game", "platforms": ["windows"], "other_platforms": "Explanatory advanced row; this setting has no macOS consumer."}, {"key": "ui.instant_warp_always_ask", "control": "list", "page": "travel.filters", "application": "reopen_view", "treatment": "in_game"}, {"key": "ui.instant_warp_auto_jump", "control": "list", "page": "travel.filters", "application": "reopen_view", "treatment": "in_game"}, {"key": "ui.instant_warp_auto_warp", "control": "list", "page": "travel.filters", "application": "reopen_view", "treatment": "in_game"}, - {"key": "ui.notify_banner_types", "control": "list", "page": "notifications", "application": "live", "treatment": "in_game"}, + {"key": "ui.notify_banner_types", "control": "list", "page": "notifications", "application": "live", "treatment": "in_game", "platforms": ["windows"], "other_platforms": "Explanatory advanced row; this setting has no macOS consumer."}, {"key": "ui.officer_sort", "control": "boolean", "page": "officers", "application": "restart", "treatment": "in_game"}, {"key": "ui.only_show_first_popup", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, {"key": "ui.pinned_ships", "control": "list", "page": "travel.filters", "application": "reopen_view", "treatment": "in_game"}, diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index b0034585a..68bcebee3 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -58,6 +58,7 @@ alert_armada_battle_lost = "none" [buffs] # This is the option to always show out of dock power for ships when they are docked. Defaults to false +# Changes require a client restart. use_out_of_dock_power = true # <[===============================================================================================]> @@ -775,6 +776,7 @@ show_player_title = true # ------------------------- # Maximum for tagged chest-purchase sliders on Windows; 0 disables the feature and native limits above this value are preserved +# Changes require a client restart. extend_chest_purchase_max = 160 # Subgroup: Daily Goals diff --git a/mods/src/config.cc b/mods/src/config.cc index 77e105946..366d78f81 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -6,12 +6,10 @@ #include "patches/runtime_config.h" #include "prime/KeyCode.h" #include "settings/upstream_settings.h" -#include "ship_name_match.h" #include "str_utils.h" #include "version.h" #include -#include #include #include "defaultconfig.h" @@ -67,49 +65,6 @@ constexpr auto kToastAudioAlerts = std::to_array({ }); } // namespace -static const eastl::tuple bannerTypes[] = { - {"All", ToastState::All}, - {"Standard", ToastState::Standard}, - {"FactionWarning", ToastState::FactionWarning}, - {"FactionLevelUp", ToastState::FactionLevelUp}, - {"FactionLevelDown", ToastState::FactionLevelDown}, - {"FactionDiscovered", ToastState::FactionDiscovered}, - {"IncomingAttackFaction", ToastState::IncomingAttackFaction}, - {"FleetBattle", ToastState::FleetBattle}, - {"Victory", ToastState::Victory}, - {"Defeat", ToastState::Defeat}, - {"Event", ToastState::Tournament}, - {"ArmadaCreated", ToastState::ArmadaCreated}, - {"ArmadaCanceled", ToastState::ArmadaCanceled}, - {"ArmadaIncomingAttack", ToastState::ArmadaIncomingAttack}, - {"ArmadaBattleWon", ToastState::ArmadaBattleWon}, - {"ArmadaBattleLost", ToastState::ArmadaBattleLost}, - {"DiplomacyUpdated", ToastState::DiplomacyUpdated}, - {"JoinedTakeover", ToastState::JoinedTakeover}, - {"CompetitorJoinedTakeover", ToastState::CompetitorJoinedTakeover}, - {"AbandonedTerritory", ToastState::AbandonedTerritory}, - {"TakeoverVictory", ToastState::TakeoverVictory}, - {"TakeoverDefeat", ToastState::TakeoverDefeat}, - {"TreasuryProgress", ToastState::TreasuryProgress}, - {"TreasuryFull", ToastState::TreasuryFull}, - {"Achievement", ToastState::Achievement}, - {"AssaultVictory", ToastState::AssaultVictory}, - {"AssaultDefeat", ToastState::AssaultDefeat}, - {"ChallengeComplete", ToastState::ChallengeComplete}, - {"ChallengeFailed", ToastState::ChallengeFailed}, - {"StrikeHit", ToastState::StrikeHit}, - {"StrikeDefeat", ToastState::StrikeDefeat}, - {"WarchestProgress", ToastState::WarchestProgress}, - {"WarchestFull", ToastState::WarchestFull}, - {"PartialVictory", ToastState::PartialVictory}, - {"ArenaTimeLeft", ToastState::ArenaTimeLeft}, - {"ChainedEventScored", ToastState::ChainedEventScored}, - {"FleetPresetApplied", ToastState::FleetPresetApplied}, - {"SurgeWarmUpEnded", ToastState::SurgeWarmUpEnded}, - {"SurgeHostileGroupDefeated", ToastState::SurgeHostileGroupDefeated}, - {"SurgeTimeLeft", ToastState::SurgeTimeLeft}, -}; - bool SyncConfig::enabled(SyncConfig::Type type) const { for (const auto& opt : SyncOptions) { @@ -568,27 +523,6 @@ float get_fleet_label_zoom_threshold(toml::table& config, toml::table& new_confi return threshold; } -bool ParseShipFilter(std::string_view value, std::vector& names, bool& match_all) -{ - names.clear(); - match_all = false; - if (AsciiStrToUpper(StripAsciiWhitespace(value)) == "*") { - match_all = true; - return true; - } - bool valid = true; - for (const auto& token : StrSplit(std::string(value), ',')) { - auto stripped = StripAsciiWhitespace(token); - if (stripped.empty()) continue; - if (auto normalized = ShipNameMatch::NormalizeKey(stripped); !normalized.empty() && stripped != "*") { - names.emplace_back(std::move(normalized)); - } else { - valid = false; - } - } - return valid; -} - void read_instant_warp_filter(toml::table& config, toml::table& new_config, std::string_view key, std::vector& names, bool& match_all, std::string_view default_value, bool write_log) @@ -604,63 +538,6 @@ void read_instant_warp_filter(toml::table& config, toml::table& new_config, std: } } -bool ParseFactionFilter(std::string_view value, std::vector& factions) -{ - static constexpr std::array kKnownFactions{"federation", "klingon", "romulan"}; - - factions.clear(); - bool valid = true; - for (const auto& token : StrSplit(std::string(value), ',')) { - const auto stripped = StripAsciiWhitespace(token); - if (stripped.empty()) continue; - - auto lowered = AsciiStrToLower(stripped); - - if (std::ranges::find(kKnownFactions, lowered) == kKnownFactions.end()) { - valid = false; - continue; - } - - if (std::ranges::find(factions, lowered) == factions.end()) { - factions.emplace_back(std::move(lowered)); - } - } - return valid; -} - -bool ParseBannerFilter(std::string_view value, std::vector& types) -{ - types.clear(); - bool valid = true; - for (const auto& token : StrSplit(std::string(value), ',')) { - const auto name = AsciiStrToUpper(StripAsciiWhitespace(token)); - if (name.empty()) - continue; - bool found = false; - for (const auto& [key, type] : bannerTypes) { - if (name == "ALL" || name == AsciiStrToUpper(key)) { - if (std::ranges::find(types, type) == types.end()) - types.push_back(type); - found = true; - } - } - valid &= found; - } - return valid; -} - -std::string FormatBannerFilter(const std::vector& types) -{ - std::string result; - for (const auto& [key, type] : bannerTypes) - if (std::ranges::find(types, type) != types.end()) { - if (!result.empty()) - result += ", "; - result += key; - } - return result; -} - void read_daily_bulk_claim_factions(toml::table& config, toml::table& new_config, std::vector& factions, std::string_view default_value, bool write_log) { diff --git a/mods/src/config_filters.cc b/mods/src/config_filters.cc new file mode 100644 index 000000000..ce65d4437 --- /dev/null +++ b/mods/src/config_filters.cc @@ -0,0 +1,129 @@ +#include "config.h" +#include "prime/Toast.h" +#include "ship_name_match.h" +#include "str_utils.h" +#include +#include + +static const std::pair bannerTypes[] = { + {"All", ToastState::All}, + {"Standard", ToastState::Standard}, + {"FactionWarning", ToastState::FactionWarning}, + {"FactionLevelUp", ToastState::FactionLevelUp}, + {"FactionLevelDown", ToastState::FactionLevelDown}, + {"FactionDiscovered", ToastState::FactionDiscovered}, + {"IncomingAttackFaction", ToastState::IncomingAttackFaction}, + {"FleetBattle", ToastState::FleetBattle}, + {"Victory", ToastState::Victory}, + {"Defeat", ToastState::Defeat}, + {"Event", ToastState::Tournament}, + {"ArmadaCreated", ToastState::ArmadaCreated}, + {"ArmadaCanceled", ToastState::ArmadaCanceled}, + {"ArmadaIncomingAttack", ToastState::ArmadaIncomingAttack}, + {"ArmadaBattleWon", ToastState::ArmadaBattleWon}, + {"ArmadaBattleLost", ToastState::ArmadaBattleLost}, + {"DiplomacyUpdated", ToastState::DiplomacyUpdated}, + {"JoinedTakeover", ToastState::JoinedTakeover}, + {"CompetitorJoinedTakeover", ToastState::CompetitorJoinedTakeover}, + {"AbandonedTerritory", ToastState::AbandonedTerritory}, + {"TakeoverVictory", ToastState::TakeoverVictory}, + {"TakeoverDefeat", ToastState::TakeoverDefeat}, + {"TreasuryProgress", ToastState::TreasuryProgress}, + {"TreasuryFull", ToastState::TreasuryFull}, + {"Achievement", ToastState::Achievement}, + {"AssaultVictory", ToastState::AssaultVictory}, + {"AssaultDefeat", ToastState::AssaultDefeat}, + {"ChallengeComplete", ToastState::ChallengeComplete}, + {"ChallengeFailed", ToastState::ChallengeFailed}, + {"StrikeHit", ToastState::StrikeHit}, + {"StrikeDefeat", ToastState::StrikeDefeat}, + {"WarchestProgress", ToastState::WarchestProgress}, + {"WarchestFull", ToastState::WarchestFull}, + {"PartialVictory", ToastState::PartialVictory}, + {"ArenaTimeLeft", ToastState::ArenaTimeLeft}, + {"ChainedEventScored", ToastState::ChainedEventScored}, + {"FleetPresetApplied", ToastState::FleetPresetApplied}, + {"SurgeWarmUpEnded", ToastState::SurgeWarmUpEnded}, + {"SurgeHostileGroupDefeated", ToastState::SurgeHostileGroupDefeated}, + {"SurgeTimeLeft", ToastState::SurgeTimeLeft}, +}; + +bool ParseShipFilter(std::string_view value, std::vector& names, bool& match_all) +{ + names.clear(); + match_all = false; + if (AsciiStrToUpper(StripAsciiWhitespace(value)) == "*") { + match_all = true; + return true; + } + bool valid = true; + for (const auto& token : StrSplit(std::string(value), ',')) { + auto stripped = StripAsciiWhitespace(token); + if (stripped.empty()) + continue; + if (auto normalized = ShipNameMatch::NormalizeKey(stripped); !normalized.empty() && stripped != "*") { + names.emplace_back(std::move(normalized)); + } else { + valid = false; + } + } + return valid; +} + +bool ParseFactionFilter(std::string_view value, std::vector& factions) +{ + static constexpr std::array kKnownFactions{"federation", "klingon", "romulan"}; + + factions.clear(); + bool valid = true; + for (const auto& token : StrSplit(std::string(value), ',')) { + const auto stripped = StripAsciiWhitespace(token); + if (stripped.empty()) + continue; + + auto lowered = AsciiStrToLower(stripped); + + if (std::ranges::find(kKnownFactions, lowered) == kKnownFactions.end()) { + valid = false; + continue; + } + + if (std::ranges::find(factions, lowered) == factions.end()) { + factions.emplace_back(std::move(lowered)); + } + } + return valid; +} + +bool ParseBannerFilter(std::string_view value, std::vector& types) +{ + types.clear(); + bool valid = true; + for (const auto& token : StrSplit(std::string(value), ',')) { + const auto name = AsciiStrToUpper(StripAsciiWhitespace(token)); + if (name.empty()) + continue; + bool found = false; + for (const auto& [key, type] : bannerTypes) { + if (name == "ALL" || name == AsciiStrToUpper(key)) { + if (std::ranges::find(types, type) == types.end()) + types.push_back(type); + found = true; + } + } + valid &= found; + } + return valid; +} + +std::string FormatBannerFilter(const std::vector& types) +{ + std::string result; + for (const auto& [key, type] : bannerTypes) + if (std::ranges::find(types, type) != types.end()) { + if (!result.empty()) + result += ", "; + result += key; + } + return result; +} diff --git a/mods/src/patches/parts/runtime_config.cc b/mods/src/patches/parts/runtime_config.cc index 68de19afa..18db45bf5 100644 --- a/mods/src/patches/parts/runtime_config.cc +++ b/mods/src/patches/parts/runtime_config.cc @@ -43,7 +43,7 @@ bool reported_save_failure = false; std::uint64_t reported_status_sequence = 0; std::atomic adapter_status_sequence{0}; using SettingKey = std::pair; -std::map unavailable_settings; +std::map unavailable_settings; #ifndef CONFIG_RUNTIME_TEST std::optional startup_values; #endif @@ -240,7 +240,7 @@ bool RegisterSetting(const char* section, const char* key) noexcept } return false; } -mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key) noexcept +mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key, const config_edit::Value& current) noexcept { using namespace mod_settings; try { @@ -248,13 +248,15 @@ mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key) noe if (!section || !key) return {SaveState::Unavailable}; std::lock_guard lock(lifecycle); - if (unavailable_settings.contains(SettingKey{section, key})) - return {SaveState::Unavailable}; + if (const auto found = unavailable_settings.find(SettingKey{section, key}); found != unavailable_settings.end()) + return {current != found->second ? SaveState::SessionOnly : SaveState::Unavailable}; if (!writer) return {}; const auto status = writer->GetStatus(section, key); if (!status.revision) return {}; + if (status.subject != current) + return {SaveState::SessionOnly, status.revision}; if (status.pending) return {SaveState::Pending, status.revision}; if (status.outcome == config_edit::Outcome::Saved || status.outcome == config_edit::Outcome::AlreadySaved) @@ -329,7 +331,7 @@ void SaveSetting(const char* section, const char* key, config_edit::Value value, if (section && key && available && !forcing && owner == CurrentThreadToken() && !quit_depth) { std::lock_guard lock(lifecycle); if (!draining) { - if (writer->Submit(section, key, std::move(value), delay)) { + if (writer->Submit(section, key, value, delay)) { if (unavailable_settings.erase(SettingKey{section, key})) { persistence_unavailable.store(!unavailable_settings.empty()); ++adapter_status_sequence; @@ -337,6 +339,10 @@ void SaveSetting(const char* section, const char* key, config_edit::Value value, return; } if (writer->HasFailure(section, key)) { + if (unavailable_settings.erase(SettingKey{section, key})) { + persistence_unavailable.store(!unavailable_settings.empty()); + ++adapter_status_sequence; + } spdlog::warn("{}.{} changed for this session; runtime save submission failed", section, key); return; // The writer owns this failure and its eventual same-key recovery. } @@ -344,7 +350,7 @@ void SaveSetting(const char* section, const char* key, config_edit::Value value, } { std::lock_guard lock(lifecycle); - unavailable_settings.insert_or_assign(SettingKey{section ? section : "", key ? key : ""}, true); + unavailable_settings.insert_or_assign(SettingKey{section ? section : "", key ? key : ""}, std::move(value)); ++adapter_status_sequence; } persistence_unavailable.store(true); diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index 3a8b2d9ed..c54d45dd2 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -1,6 +1,7 @@ #include "config.h" #include "errormsg.h" #include "patches/native_hook_extent.h" +#include "settings/upstream_settings.h" #include @@ -326,11 +327,13 @@ vec3 GetMouseWorldPos(void *cam, vec3 *pos) auto do_default_zoom = false; -inline void StoreZoom(std::string label, float &zoom, NavigationZoom *_this) +inline void StoreZoom(std::string label, float &zoom, NavigationZoom *_this, const char *key = nullptr) { auto old_zoom = zoom; zoom = (_this->Distance - _this->_minimum) / (_this->_maximum - _this->_minimum) * Config::Get().zoom; spdlog::info("Changing {} from {} to {}", label, old_zoom, zoom); + if (key) + mod_settings::PersistCurrentSetting("graphics", key); } static float s_expectedScale = 0; @@ -451,17 +454,17 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom *_this) if (!Key::IsInputFocused()) { if (camera_shortcuts_enabled) { if (MapKey::IsDown(GameFunction::SetZoomPreset1)) { - return StoreZoom("System Preset 1", config->system_zoom_preset_1, _this); + return StoreZoom("System Preset 1", config->system_zoom_preset_1, _this, "system_zoom_preset_1"); } else if (MapKey::IsDown(GameFunction::SetZoomPreset2)) { - return StoreZoom("System Preset 2", config->system_zoom_preset_2, _this); + return StoreZoom("System Preset 2", config->system_zoom_preset_2, _this, "system_zoom_preset_2"); } else if (MapKey::IsDown(GameFunction::SetZoomPreset3)) { - return StoreZoom("System Preset 3", config->system_zoom_preset_3, _this); + return StoreZoom("System Preset 3", config->system_zoom_preset_3, _this, "system_zoom_preset_3"); } else if (MapKey::IsDown(GameFunction::SetZoomPreset4)) { - return StoreZoom("System Preset 4", config->system_zoom_preset_4, _this); + return StoreZoom("System Preset 4", config->system_zoom_preset_4, _this, "system_zoom_preset_4"); } else if (MapKey::IsDown(GameFunction::SetZoomPreset5)) { - return StoreZoom("System Preset 5", config->system_zoom_preset_5, _this); + return StoreZoom("System Preset 5", config->system_zoom_preset_5, _this, "system_zoom_preset_5"); } else if (MapKey::IsDown(GameFunction::SetZoomDefault)) { - return StoreZoom("System Default", config->default_system_zoom, _this); + return StoreZoom("System Default", config->default_system_zoom, _this, "default_system_zoom"); } do_absolute_zoom = true; diff --git a/mods/src/patches/runtime_config.h b/mods/src/patches/runtime_config.h index a2f8b66a4..f81b74523 100644 --- a/mods/src/patches/runtime_config.h +++ b/mods/src/patches/runtime_config.h @@ -16,7 +16,8 @@ bool HasSaveFailures() noexcept; // Definitions register their key once. Repeated registrations preserve the // original startup comparison value and do not read or write the disk. bool RegisterSetting(const char* section, const char* key) noexcept; -mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key) noexcept; +mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key, + const config_edit::Value& current) noexcept; std::uint64_t StatusSequence() noexcept; void SaveSetting(const char* section, const char* key, config_edit::Value value, std::chrono::milliseconds delay = {}) noexcept; diff --git a/mods/src/runtime_config_writer.cc b/mods/src/runtime_config_writer.cc index 3b849cc9b..174b04bb5 100644 --- a/mods/src/runtime_config_writer.cc +++ b/mods/src/runtime_config_writer.cc @@ -49,7 +49,7 @@ std::uint64_t RuntimeConfigWriter::Submit(std::string section, std::string key, Pending{++revision_, {std::move(section), std::move(key), saved->second.value, std::move(desired)}, std::chrono::steady_clock::now() + delay}); - saved->second.status = {revision_, saved->second.status.outcome, true, true}; + saved->second.status = {revision_, saved->second.status.outcome, true, true, pending_.at(identity).edit.desired}; ++status_sequence_; has_work_.store(true); if (!worker_.joinable()) { @@ -60,7 +60,7 @@ std::uint64_t RuntimeConfigWriter::Submit(std::string section, std::string key, has_work_.store(false); completion_ = {revision_, Outcome::IoError}; saved->second.failed = true; - saved->second.status = {revision_, Outcome::IoError, false, true}; + saved->second.status = {revision_, Outcome::IoError, false, true, saved->second.status.subject}; ++status_sequence_; has_failures_.store(true); return 0; @@ -81,7 +81,7 @@ void RuntimeConfigWriter::Stop(bool cancel_pending) for (const auto& [identity, pending] : pending_) { auto& saved = saved_.at(identity); if (saved.status.revision == pending.revision) { - saved.status = {pending.revision, Outcome::Cancelled, false, true}; + saved.status = {pending.revision, Outcome::Cancelled, false, true, pending.edit.desired}; saved.failed = true; } } @@ -130,7 +130,7 @@ void RuntimeConfigWriter::Run() for (const auto& [identity, pending] : pending_) { auto& saved = saved_.at(identity); if (saved.status.revision == pending.revision) { - saved.status = {pending.revision, Outcome::Cancelled, false, true}; + saved.status = {pending.revision, Outcome::Cancelled, false, true, pending.edit.desired}; saved.failed = true; } } @@ -176,7 +176,7 @@ void RuntimeConfigWriter::Run() if (status.revision == work.revision || status.pending) saved_state.failed = outcome != Outcome::Saved && outcome != Outcome::AlreadySaved; if (status.revision == work.revision) - status = {work.revision, outcome, false, true}; + status = {work.revision, outcome, false, true, work.edit.desired}; ++status_sequence_; has_failures_.store( std::any_of(saved_.begin(), saved_.end(), [](const auto& entry) { return entry.second.failed; })); diff --git a/mods/src/runtime_config_writer.h b/mods/src/runtime_config_writer.h index 5ffc24e8b..dbc23157e 100644 --- a/mods/src/runtime_config_writer.h +++ b/mods/src/runtime_config_writer.h @@ -27,6 +27,7 @@ class RuntimeConfigWriter Outcome outcome = Outcome::AlreadySaved; bool pending = false; bool registered = false; + std::optional subject; // Value described by this revision, including pending/failed attempts. }; explicit RuntimeConfigWriter(std::filesystem::path path, Reporter report = nullptr); ~RuntimeConfigWriter(); // Tests/explicit owners only; game adapter has process lifetime. diff --git a/mods/src/settings/boolean_settings.h b/mods/src/settings/boolean_settings.h index 693135b83..17ea419d8 100644 --- a/mods/src/settings/boolean_settings.h +++ b/mods/src/settings/boolean_settings.h @@ -14,44 +14,4 @@ class BooleanSetting : public ValueSetting }; enum class Registration { Added, Duplicate, Invalid, Frozen }; -class BooleanRegistry -{ -public: - Registration Register(Definition definition) - { - CheckThread(); - if (frozen_) - return Registration::Frozen; - if (definition.id.empty() || definition.label.empty() || !definition.read || !definition.write) - return Registration::Invalid; - if (settings_.contains(definition.id)) - return Registration::Duplicate; - auto id = definition.id; - settings_.try_emplace(std::move(id), std::move(definition)); - return Registration::Added; - } - BooleanSetting* Find(const std::string& id) - { - CheckThread(); - frozen_ = true; - auto it = settings_.find(id); - return it == settings_.end() ? nullptr : &it->second; - } - void InvalidateSession() - { - CheckThread(); - for (auto& [id, setting] : settings_) - setting.InvalidateSession(); - } - -private: - void CheckThread() const - { - if (std::this_thread::get_id() != thread_) - throw std::logic_error("registry thread mismatch"); - } - const std::thread::id thread_ = std::this_thread::get_id(); - bool frozen_ = false; - std::map settings_; -}; } // namespace mod_settings diff --git a/mods/src/settings/maintenance.h b/mods/src/settings/maintenance.h index cc270b37f..6a5eed733 100644 --- a/mods/src/settings/maintenance.h +++ b/mods/src/settings/maintenance.h @@ -160,9 +160,11 @@ inline void RegisterMaintenancePages(PageCatalog& catalog) retry->read = [control](std::size_t) { const auto status = control.status ? control.status() : std::string{}; const auto state = control.save_status ? control.save_status().state : SaveState::None; - const bool failed = - state == SaveState::Conflict || state == SaveState::Failed || state == SaveState::Unavailable; - return ActionSetting::Presentation{control.label, "Retry save", status, !!control.retry, failed}; + const bool failed = state == SaveState::Conflict || state == SaveState::Failed + || state == SaveState::Unavailable || state == SaveState::SessionOnly; + return ActionSetting::Presentation{control.label, + state == SaveState::SessionOnly ? "Save current value" : "Retry save", + status, !!control.retry, failed}; }; retry->invoke = [control](std::size_t) { if (control.retry) diff --git a/mods/src/settings/persisted_setting.h b/mods/src/settings/persisted_setting.h index 6dca5622c..a1e289735 100644 --- a/mods/src/settings/persisted_setting.h +++ b/mods/src/settings/persisted_setting.h @@ -22,10 +22,12 @@ ValueDefinition PersistedDefinition(ValueDefinition definition, SettingMet definition.default_value = std::move(default_value); const auto section = definition.metadata.section; const auto key = definition.metadata.key; - definition.persist = [section, key, serialize = std::move(serialize), delay](const T& value) { + definition.persist = [section, key, serialize, delay](const T& value) { runtime_config::SaveSetting(section.c_str(), key.c_str(), serialize(value), delay); }; - definition.save_status = [section, key] { return runtime_config::GetSaveStatus(section.c_str(), key.c_str()); }; + definition.save_status = [section, key, serialize = std::move(serialize)](const T& value) { + return runtime_config::GetSaveStatus(section.c_str(), key.c_str(), serialize(value)); + }; return definition; } diff --git a/mods/src/settings/setting_metadata.h b/mods/src/settings/setting_metadata.h index c0169e34a..2663a8e77 100644 --- a/mods/src/settings/setting_metadata.h +++ b/mods/src/settings/setting_metadata.h @@ -7,7 +7,7 @@ namespace mod_settings { enum class ApplyTiming { Live, ReopenView, Restart }; -enum class SaveState { None, Pending, Saved, Conflict, Failed, Unavailable }; +enum class SaveState { None, Pending, Saved, Conflict, Failed, Unavailable, SessionOnly }; struct SaveStatus { SaveState state = SaveState::None; std::uint64_t revision = 0; @@ -30,6 +30,8 @@ struct PreparedSettingAction { inline const char* SaveStatusText(SaveStatus status) { switch (status.state) { + case SaveState::SessionOnly: + return "Applied for this session; not saved"; case SaveState::Pending: return "Saving..."; case SaveState::Saved: diff --git a/mods/src/settings/upstream_settings.cc b/mods/src/settings/upstream_settings.cc index 4574e1dbc..8f33e32c3 100644 --- a/mods/src/settings/upstream_settings.cc +++ b/mods/src/settings/upstream_settings.cc @@ -127,8 +127,10 @@ namespace auto result = PersistedDefinition( std::move(definition), {section, key, help, unit, timing}, std::move(defaultValue), std::move(serializer), std::is_floating_point_v ? std::chrono::milliseconds(250) : std::chrono::milliseconds{}); - currentSavers.insert_or_assign(result.metadata.TomlKey(), [read, available, persist = result.persist] { - if (!available() || !persist) + // A native action already admitted the mutation. Its save does not depend + // on whether the corresponding editor/sorting feature is available. + currentSavers.insert_or_assign(result.metadata.TomlKey(), [read, persist = result.persist] { + if (!persist) return false; persist(read()); return true; @@ -389,6 +391,10 @@ namespace return ParseFactionFilter(text, values) ? std::optional{Join(values)} : std::nullopt; }); for (const bool notify : {false, true}) { +#if !_WIN32 + if (notify) + continue; // Native notification filtering consumes this list only on Windows. +#endif auto setting = Keep>(MakeDefinition( "ui", notify ? "notify_banner_types" : "disabled_banner_types", notify ? "Banners allowed to notify" : "Hidden banner types", @@ -435,13 +441,26 @@ void RegisterUpstreamSettings(PageCatalog& catalog) constexpr float positive = std::numeric_limits::min(); constexpr float largest = std::numeric_limits::max(); constexpr int integer = std::numeric_limits::max(); - const Available always = [] { return true; }; namespace C = DefaultConfig::Control; namespace G = DefaultConfig::Graphics; namespace U = DefaultConfig::UI; namespace B = DefaultConfig::Buffs; const auto hotkeys = Hook(&Config::installHotkeyHooks); const auto zoom = Hook(&Config::installZoomHooks); + const auto pan = Hook(&Config::installPanHooks); + const Available controls = [hotkeys, zoom, pan] { return hotkeys() || zoom() || pan(); }; + const Available extended = [hotkeys, zoom] { return hotkeys() || zoom(); }; + const Available protections = [] { + const auto& config = Config::Get(); + bool enabled = config.installQueueAddressGuardHooks; +#if defined(_WIN32) && defined(_M_X64) + enabled = enabled || config.installActionQueueRecoveryHooks || config.installThinQueueProtectionHooks; +#endif + return enabled; + }; + const Available queue = [hotkeys, protections] { + return hotkeys() || protections() || Config::Get().installTestPatches; + }; const Available fleetLabels = [zoom] { return zoom() && FleetLabelControlsAvailable(); }; const auto scale = Hook(&Config::installUiScaleHooks); const auto misc = Hook(&Config::installMiscPatches); @@ -469,19 +488,19 @@ void RegisterUpstreamSettings(PageCatalog& catalog) Number(catalog, "community_mod." page, section, key, label, &Config::member, decltype(Config::member)(def), hook, \ Timing::timing, help, unit, decltype(Config::member)(low), decltype(Config::member)(high), slider, step) - BOOL("controls", "control", "hotkeys_enabled", "Enable mod shortcuts", hotkeys_enabled, C::hotkeys_enabled, hotkeys, + BOOL("controls", "control", "hotkeys_enabled", "Enable mod shortcuts", hotkeys_enabled, C::hotkeys_enabled, controls, Live, "Use the mod's configured keyboard and mouse shortcuts."); BOOL("controls", "control", "hotkeys_extended", "Extended shortcut actions", hotkeys_extended, C::hotkeys_extended, - hotkeys, Restart, + extended, Restart, "Enable the extended set of mod shortcut actions. Restart to change their startup availability."); BOOL("controls", "control", "use_scopely_hotkeys", "Use native game shortcuts", use_scopely_hotkeys, - C::use_scopely_hotkeys, hotkeys, Restart, + C::use_scopely_hotkeys, controls, Restart, "The game uses its own shortcuts instead of the mod's shortcut bindings."); Boolean(catalog, "community_mod.controls", "control", "queue_enabled", "Enable action queue", &Config::queue_enabled, - C::queue_enabled, hotkeys, Timing::Live, "Allow queued actions from the mod's queue shortcut.", + C::queue_enabled, queue, Timing::Live, "Allow queued actions from the mod's queue shortcut.", ResetQueueRecoveryTracking); Boolean(catalog, "community_mod.controls", "control", "kirshara_fixes", "Kirshara queue protections", - &Config::kirshara_fixes, C::kirshara_fixes, always, Timing::Live, + &Config::kirshara_fixes, C::kirshara_fixes, protections, Timing::Live, "Protect and recover queued actions when targets or addresses change.", ResetQueueRecoveryTracking); BOOL("controls", "control", "primary_requires_visible_target", "Require visible primary target", primary_requires_visible_target, C::primary_requires_visible_target, hotkeys, Live, @@ -499,8 +518,6 @@ void RegisterUpstreamSettings(PageCatalog& catalog) "distance", 0, largest, 6000, 250); NUM("camera", "graphics", "default_system_zoom", "Default system zoom", default_system_zoom, G::default_system_zoom, zoom, Live, "Camera distance used by reset/default zoom.", "distance", 0, largest, 6000, 50); - NUM("camera", "graphics", "transition_time", "Camera transition time", transition_time, G::transition_time, zoom, - Live, "Duration of camera zoom transitions.", "s", 0, largest, 2, .01f); NUM("camera", "graphics", "system_pan_momentum_falloff", "Pan glide", system_pan_momentum_falloff, G::system_pan_momentum_falloff, Hook(&Config::installPanHooks), Live, "Retained motion per pan update. Higher values glide longer; values below 1 eventually stop.", "ratio", 0, @@ -531,9 +548,10 @@ void RegisterUpstreamSettings(PageCatalog& catalog) NUM("interface", "graphics", "fr_scale", "Planet backdrop scale", fr_scale, G::fr_scale, zoom, Restart, "Multiplier for the system planet backdrop. Restart avoids rescaling an already modified transform.", "multiplier", positive, largest, 4, .1f); - BOOL("interface", "graphics", "allow_cursor", "Allow cursor changes", allow_cursor, G::allow_cursor, scale, Live, - "Allow the game to update the mouse cursor."); #if _WIN32 + BOOL("interface", "graphics", "allow_cursor", "Allow cursor changes", allow_cursor, G::allow_cursor, + ([] { return Config::Get().installUiScaleHooks || Config::Get().installTestPatches; }), Live, + "Allow the game to update the mouse cursor."); BOOL("interface", "graphics", "free_resize", "Free window resizing", free_resize, G::free_resize, Hook(&Config::installFreeResizeHooks), Restart, "Allow arbitrary window sizes on Windows."); BOOL("interface", "graphics", "borderless_fullscreen", "Borderless fullscreen", borderless_fullscreen, @@ -548,9 +566,10 @@ void RegisterUpstreamSettings(PageCatalog& catalog) "Zero disables double-tap. Otherwise two Escape presses within this interval may open the exit prompt.", "ms", 0, integer, 0, 1); BOOL("interface", "ui", "disable_move_keys", "Disable keyboard movement", disable_move_keys, U::disable_move_keys, - hotkeys, Live, "Disable map movement using configured movement shortcuts."); + Hook(&Config::installPanHooks), Live, "Disable map movement using configured movement shortcuts."); BOOL("interface", "ui", "only_show_first_popup", "Show only the first startup popup", only_show_first_popup, - U::only_show_first_popup, misc, Live, "Suppress additional startup interstitial popups in this session."); + U::only_show_first_popup, Hook(&Config::installTempCrashFixes), Live, + "Suppress additional startup interstitial popups in this session."); BOOL("travel", "graphics", "galactic_anomaly_timer", "Galactic Anomaly timer", galactic_anomaly_timer, G::galactic_anomaly_timer, Hook(&Config::installGalacticAnomalyTimerHooks), Live, @@ -571,9 +590,11 @@ void RegisterUpstreamSettings(PageCatalog& catalog) Live, "Automatically show cargo for hostiles."); BOOL("previews", "ui", "show_armada_cargo", "Armada cargo", show_armada_cargo, U::show_armada_cargo, hotkeys, Live, "Automatically show cargo for Armada targets."); +#if defined(_WIN32) && defined(_M_X64) BOOL("previews", "ui", "instant_cargo_counter", "Instant ship cargo counter", instant_cargo_counter, U::instant_cargo_counter, Hook(&Config::installInstantCargoCounterHooks), Live, "Remove the animation delay from supported ship cargo counters."); +#endif BOOL("previews", "ui", "format_cargo_values", "Format cargo values", format_cargo_values, U::format_cargo_values, Hook(&Config::installCargoFormatHooks), Restart, "Use the configured decimal precision for abbreviated cargo numbers."); @@ -589,7 +610,8 @@ void RegisterUpstreamSettings(PageCatalog& catalog) Hook(&Config::installDailyFactionBulkClaimHooks), Restart, "Enable the daily faction Claim All toggle when first initialized in a session."); BOOL("claims", "ui", "always_skip_reveal_sequence", "Skip reward reveal sequences", always_skip_reveal_sequence, - U::always_skip_reveal_sequence, misc, Live, "Skip the animated reward reveal and show the summary directly."); + U::always_skip_reveal_sequence, Hook(&Config::installTempCrashFixes), Live, + "Skip the animated reward reveal and show the summary directly."); catalog.AddBoolean("community_mod.claims", ForbiddenTechConfirmationSetting()); BOOL("claims", "ui", "auto_confirm_reveal_ability", "Automatically confirm Excelsior reveal", auto_confirm_reveal_ability, U::auto_confirm_reveal_ability, Hook(&Config::installRevealConfirmationHooks), Live, @@ -598,8 +620,10 @@ void RegisterUpstreamSettings(PageCatalog& catalog) U::disable_exchange_all, Hook(&Config::installArtifactExchangeHooks), ReopenView, "Hide Exchange All in the artifact popup. Reopen the popup after changing this."); NUM("claims", "ui", "extend_chest_purchase_max", "Maximum chest quantity", extend_chest_purchase_max, - U::extend_chest_purchase_max, misc, ReopenView, - "Extended purchase/claim quantity limit in supported chest dialogs.", "chests", 1, integer, 0, 1); + U::extend_chest_purchase_max, misc, Restart, + "Zero disables the extension. The effective extension is capped at 160; higher native limits are preserved. " + "Restart after changing this value.", + "chests", 0, integer, 0, 1); BOOL("claims", "ui", "extend_donation_slider", "Extend donation slider", extend_donation_slider, U::extend_donation_slider, misc, ReopenView, "Use the extended donation quantity limit in supported donation dialogs."); @@ -619,8 +643,8 @@ void RegisterUpstreamSettings(PageCatalog& catalog) BOOL("officers", "ui", "dock_syncmanagement", "Keep dock selection in sync", dock_syncmanagement, U::dock_syncmanagement, hotkeys, Live, "Keep dock and Manage Ships selections aligned."); BOOL("officers", "buffs", "use_out_of_dock_power", "Use out-of-dock power", use_out_of_dock_power, - B::use_out_of_dock_power, Hook(&Config::installBuffFixHooks), Live, - "Use deployed-ship power calculations for supported power displays."); + B::use_out_of_dock_power, Hook(&Config::installBuffFixHooks), Restart, + "Use deployed-ship power calculations for supported power displays. Restart after changing this value."); BOOL("officers", "ui", "reverse_haven_history", "Newest Haven help first", reverse_haven_history, U::reverse_haven_history, Hook(&Config::installHavenHistoryHooks), ReopenView, "Reverse Haven help history into newest-first order when the list refreshes."); @@ -634,9 +658,6 @@ void RegisterUpstreamSettings(PageCatalog& catalog) Hook(&Config::installChatPatches), Restart, "Disable galaxy chat tabs and preview messages."); BOOL("chat", "ui", "disable_veil_chat", "Disable Veil chat", disable_veil_chat, U::disable_veil_chat, Hook(&Config::installChatPatches), Restart, "Disable Veil chat tabs and preview messages where available."); - BOOL("notifications", "ui", "disable_toast_banners", "Hide toast banners", disable_toast_banners, - U::disable_toast_banners, toast, Live, - "Hide supported game toast banners; per-type exceptions can be configured below."); #undef BOOL #undef NUM @@ -760,16 +781,18 @@ void RegisterUpstreamSettings(PageCatalog& catalog) Boolean(catalog, "community_mod.loading", "graphics", "loader_tip_enabled", "Loading tips", &Config::loader_tip_enabled, G::loader_tip_enabled, Hook(&Config::installLoadingTipHooks), Timing::Restart, "Show custom tips on supported loading screens."); - Number(catalog, "community_mod.loading", "graphics", "loader_logo_scale", "Loading logo scale", - &Config::loader_logo_scale, static_cast(G::loader_logo_scale), loading, Timing::Restart, - "Multiplier for the loading-screen logo.", "multiplier", positive, largest, 3, .1f); + Number( + catalog, "community_mod.loading", "graphics", "loader_logo_scale", "Loading logo scale", + &Config::loader_logo_scale, static_cast(G::loader_logo_scale), + [loading, transition] { return loading() || transition(); }, Timing::Restart, + "Multiplier for the loading-screen logo.", "multiplier", positive, largest, 3, .1f); auto image = Keep>(MakeDefinition( "graphics", "loader_image", "Loading image path", G::loader_image, [] { return Config::Get().loader_image; }, [](const std::string& value) { Config::Get().loader_image = value; return true; }, - loading, Timing::Restart, + [loading, transition] { return loading() || transition(); }, Timing::Restart, "Path to an image readable by the game. Empty uses the embedded fallback. This does not move or copy the " "image.")); ExactEntry(catalog, "community_mod.loading", image, [](std::string_view value) -> std::optional { @@ -809,12 +832,14 @@ void RegisterUpstreamSettings(PageCatalog& catalog) Information(catalog, "community_mod.advanced", "community_mod.configuration.experimental", "Experimental shortcut options", [] { return "control.enable_experimental controls startup availability of experimental shortcuts. " - "Enable it in TOML and restart. graphics.system_pan_momentum is a legacy input with no " - "current gameplay consumer; pan glide uses system_pan_momentum_falloff."; + "Enable it in TOML and restart. graphics.system_pan_momentum, graphics.transition_time and " + "ui.disable_toast_banners are legacy inputs with no current gameplay consumer. " + "Pan glide uses system_pan_momentum_falloff; banner suppression uses disabled_banner_types."; }); #if !defined(_WIN32) Information(catalog, "community_mod.advanced", "community_mod.configuration.windows", "Windows window options", [] { - return "graphics.free_resize and graphics.borderless_fullscreen apply only to the Windows client."; + return "graphics.free_resize, graphics.borderless_fullscreen, graphics.allow_cursor, ui.instant_cargo_counter " + "and ui.notify_banner_types have Windows-only consumers and are not offered on this client."; }); #endif } diff --git a/mods/src/settings/value_settings.h b/mods/src/settings/value_settings.h index bfa8da1bd..838a2f083 100644 --- a/mods/src/settings/value_settings.h +++ b/mods/src/settings/value_settings.h @@ -37,7 +37,7 @@ template struct ValueDefinition { std::optional default_value; // Called only for an explicit, verified user intent, never by Observe/render. std::function persist; - std::function save_status; + std::function save_status; // Restart-only adapters expose their still-active value independently from // the requested value returned by read. std::function()> active_value; @@ -76,13 +76,16 @@ template class ValueSetting { return definition_.metadata; } const std::optional& default_value() const { return definition_.default_value; } - SaveStatus save_status() const + SaveStatus save_status() { CheckThread(); - if (persistence_failed_) - return {SaveState::Unavailable}; try { - return definition_.save_status ? definition_.save_status() : SaveStatus{}; + const auto current = Observe(); + if (!current.state.known()) + return {}; + if (persistence_failed_ && *persistence_failed_ == *current.state.value) + return {SaveState::Unavailable}; + return definition_.save_status ? definition_.save_status(*current.state.value) : SaveStatus{}; } catch (...) { return {SaveState::Unavailable}; } @@ -112,6 +115,9 @@ template class ValueSetting const bool pending_restart = restart_pending(); std::string text; switch (status.state) { + case SaveState::SessionOnly: + text = "Applied for this session; not saved"; + break; case SaveState::Pending: text = restart ? "Saving..." : "Applied; saving..."; break; @@ -292,9 +298,9 @@ template class ValueSetting try { if (definition_.persist) definition_.persist(value); - persistence_failed_ = false; + persistence_failed_.reset(); } catch (...) { - persistence_failed_ = true; + persistence_failed_ = value; } } void NotifyChanged() @@ -320,7 +326,7 @@ template class ValueSetting unsigned render_depth_ = 0; bool applying_ = false; bool observing_ = false; - bool persistence_failed_ = false; + std::optional persistence_failed_; void (*change_observer_)() = nullptr; }; diff --git a/mods/src/settings/warp_mode.cc b/mods/src/settings/warp_mode.cc index ffeb4a2b1..d3899f8dd 100644 --- a/mods/src/settings/warp_mode.cc +++ b/mods/src/settings/warp_mode.cc @@ -46,12 +46,6 @@ ChoiceSetting& WarpModeSetting() return setting; } -bool SetWarpMode(InstantWarpConfirmation desired) -{ - auto& setting = WarpModeSetting().state(); - const auto result = setting.SetFromUser(static_cast(desired), setting.Observe()); - return result.outcome == Outcome::AppliedVerified || result.outcome == Outcome::Unchanged; -} void CycleWarpMode() { auto& setting = WarpModeSetting().state(); diff --git a/mods/src/settings/warp_mode.h b/mods/src/settings/warp_mode.h index 175a540be..26083c895 100644 --- a/mods/src/settings/warp_mode.h +++ b/mods/src/settings/warp_mode.h @@ -1,14 +1,11 @@ #pragma once #include "choice_setting.h" -enum class InstantWarpConfirmation; - namespace mod_settings { -// Shared mutation path for the shortcut and the forthcoming selection control. +// Shared mutation path for the shortcut and selection control. // Changes live behavior immediately; persistence remains best-effort and reports // failures through the existing runtime writer. Call on the game UI thread. -bool SetWarpMode(InstantWarpConfirmation desired); void CycleWarpMode(); ChoiceSetting& WarpModeSetting(); } // namespace mod_settings diff --git a/mods/src/str_utils.h b/mods/src/str_utils.h index 2c153891d..8a61d8cf4 100644 --- a/mods/src/str_utils.h +++ b/mods/src/str_utils.h @@ -57,15 +57,15 @@ constexpr std::string_view StripPrefix(std::string_view str, const std::string_v constexpr std::string AsciiStrToUpper(const std::string_view s) { - std::string str = s.data(); - std::ranges::transform(str, str.begin(), ::toupper); + std::string str(s); + std::ranges::transform(str, str.begin(), [](unsigned char c) { return static_cast(std::toupper(c)); }); return str; } constexpr std::string AsciiStrToLower(const std::string_view s) { - std::string str = s.data(); - std::ranges::transform(str, str.begin(), ::tolower); + std::string str(s); + std::ranges::transform(str, str.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return str; } diff --git a/tests/boolean_settings_test.cc b/tests/boolean_settings_test.cc index d3c533409..c1118f6c6 100644 --- a/tests/boolean_settings_test.cc +++ b/tests/boolean_settings_test.cc @@ -46,15 +46,9 @@ int main() { { Fake f; - BooleanRegistry registry; - CHECK(registry.Register(f.definition()) == Registration::Added); - CHECK(registry.Register(f.definition()) == Registration::Duplicate); - CHECK(registry.Register({}) == Registration::Invalid); + BooleanSetting setting(f.definition()); CHECK(f.reads == 0 && f.writes == 0); // Construction schedules no work. - auto* s = registry.Find("test.confirm"); - CHECK(s); - CHECK(registry.Register(f.definition("late")) == Registration::Frozen); - CHECK(!registry.Find("missing")); + auto* s = &setting; auto initial = s->Observe(); CHECK(initial.state.known() && !*initial.state.value); CHECK(s->SetFromUser(false, initial).outcome == Outcome::Unchanged); @@ -189,6 +183,5 @@ int main() other.join(); CHECK(rejected && f.reads == 0); } - std::cout - << "PASS boolean settings: registration, readback, failure, refresh, reentry, generation and thread contracts\n"; + std::cout << "PASS boolean settings: readback, failure, refresh, reentry, generation and thread contracts\n"; } diff --git a/tests/persisted_setting_test.cc b/tests/persisted_setting_test.cc index 15c29fab6..609f2ba9b 100644 --- a/tests/persisted_setting_test.cc +++ b/tests/persisted_setting_test.cc @@ -22,10 +22,14 @@ void SaveSetting(const char* section, const char* key, config_edit::Value value, values.insert_or_assign(Key{section, key}, std::move(value)); ++saves; } -mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key) noexcept +mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key, const config_edit::Value& current) noexcept { - return values.contains(Key{section, key}) ? mod_settings::SaveStatus{mod_settings::SaveState::Saved, saves} - : mod_settings::SaveStatus{}; + const auto found = values.find(Key{section, key}); + return found != values.end() + ? mod_settings::SaveStatus{found->second == current ? mod_settings::SaveState::Saved + : mod_settings::SaveState::SessionOnly, + saves} + : mod_settings::SaveStatus{}; } } // namespace runtime_config @@ -48,6 +52,9 @@ int main() assert(std::get(values.at(Key("ui", "disable_feature")))); assert(setting.metadata().TomlKey() == "ui.disable_feature"); assert(setting.save_status().state == SaveState::Saved); + current = true; // A native mutation before this controller observes is not the acknowledged value. + assert(setting.save_status().state == SaveState::SessionOnly); + current = false; assert(setting.Reset(setting.Observe()).outcome == Outcome::AppliedVerified); assert(!std::get(values.at(Key("ui", "disable_feature")))); @@ -60,6 +67,18 @@ int main() }}, {"control", "number"}, 2, std::chrono::milliseconds(150))); assert(numeric.SetFromUser(3, numeric.Observe()).outcome == Outcome::AppliedVerified); + auto mirrorDefinition = + PersistedDefinition({"mirror", "Number mirror", [&] { return ValueReadResult::Known(number, 1); }, + [&](int value, std::uint64_t) { + number = value; + return ApplyResult::Applied; + }}, + {"control", "number"}, 2); + ValueSetting mirror(std::move(mirrorDefinition)); + assert(mirror.save_status().state == SaveState::Saved); + number = 4; + assert(numeric.save_status().state == SaveState::SessionOnly && mirror.save_status().state == SaveState::SessionOnly); + assert(numeric.status_text() == "Applied for this session; not saved"); assert(std::get(values.at(Key("control", "number"))) == 3); assert(registrations.size() == 2); // No independent permitted-key whitelist. bool invalid = false; diff --git a/tests/run-settings.ps1 b/tests/run-settings.ps1 index 0780b90cb..c96c2342d 100644 --- a/tests/run-settings.ps1 +++ b/tests/run-settings.ps1 @@ -56,6 +56,11 @@ try { $JsonInclude = $header.Directory.Parent.FullName } New-Item -ItemType Directory -Force $fixtureRoot | Out-Null + $packageIncludes = @('s/simdutf', 'e/eastl') | ForEach-Object { + $package = Join-Path $env:LOCALAPPDATA ('.xmake/packages/' + $_) + Get-ChildItem -LiteralPath $package -Directory -Recurse | + Where-Object Name -eq 'include' | Select-Object -First 1 -ExpandProperty FullName + } foreach ($fixture in @('boolean_settings', 'boolean_view', 'native_boolean_callback', 'page_catalog', 'page_sections', 'settings_search', 'choice_setting', 'slider_setting', 'shortcut_editor', 'settings_metadata', 'persisted_setting', 'settings_maintenance', 'input_release')) { @@ -63,15 +68,12 @@ try { } Invoke-SettingsFixture -Name 'upstream_settings' -Sources @('tests/upstream_settings_test.cc', 'mods/src/settings/upstream_settings.cc', 'mods/src/settings/warp_mode.cc', - 'mods/src/settings/forbidden_tech.cc') -IncludeDirectories @('tests/upstream_settings_fixture', $JsonInclude) ` + 'mods/src/settings/forbidden_tech.cc', 'mods/src/config_filters.cc') ` + -IncludeDirectories (@('tests/upstream_settings_fixture', $JsonInclude) + $packageIncludes) ` + -LinkArguments @('/link', '/NODEFAULTLIB:GameAssembly.lib') ` -TestArguments @('docs/settings-coverage.json', (Join-Path $fixtureRoot 'catalog')) Invoke-SettingsFixture -Name 'runtime_config' -Sources @('tests/runtime_config_test.cc') Invoke-SettingsFixture -Name 'runtime_config_writer' -Sources @('tests/runtime_config_writer_test.cc') - $packageIncludes = @('s/simdutf', 'e/eastl') | ForEach-Object { - $package = Join-Path $env:LOCALAPPDATA ('.xmake/packages/' + $_) - Get-ChildItem -LiteralPath $package -Directory -Recurse | - Where-Object Name -eq 'include' | Select-Object -First 1 -ExpandProperty FullName - } Invoke-SettingsFixture -Name 'shortcut_options' -Sources @('tests/shortcut_hint_cache.cc', 'mods/src/patches/mapkey.cc', 'mods/src/patches/modifierkey.cc') -IncludeDirectories $packageIncludes ` -LinkArguments @('/link', '/NODEFAULTLIB:GameAssembly.lib') diff --git a/tests/runtime_config_fixture.h b/tests/runtime_config_fixture.h index 640eb27a8..d32eb9a74 100644 --- a/tests/runtime_config_fixture.h +++ b/tests/runtime_config_fixture.h @@ -27,13 +27,15 @@ struct RuntimeConfigWriter { std::uint64_t revision = 0; Outcome outcome = Outcome::AlreadySaved; bool pending = false, registered = true; + std::optional subject; }; std::uint64_t StatusSequence() const { return submissions; } bool Register(std::string, std::string, std::optional) { return !stopped; } Status GetStatus(std::string_view, std::string_view) const - { return {submissions, failures ? Outcome::IoError : Outcome::Saved, work, true}; } + { return {submissions, failures ? Outcome::IoError : Outcome::Saved, work, true, desired}; } + std::optional desired; bool failures = false; bool HasFailures() const { return failures; } @@ -72,8 +74,11 @@ struct RuntimeConfigWriter { { return handle; } unsigned Submit(const char*) { return stopped || failures ? 0 : ++submissions; } - unsigned Submit(const char*, const char*, Value, std::chrono::milliseconds) - { return Submit(""); } + unsigned Submit(const char*, const char*, Value value, std::chrono::milliseconds) + { + desired = std::move(value); + return Submit(""); + } }; } // namespace config_edit diff --git a/tests/runtime_config_test.cc b/tests/runtime_config_test.cc index 8e625ac3e..b68e16f10 100644 --- a/tests/runtime_config_test.cc +++ b/tests/runtime_config_test.cc @@ -82,14 +82,29 @@ int main(int argc, char** argv) available = false; runtime_config::SaveSetting("ui", "first", true, {}); runtime_config::SaveSetting("ui", "second", true, {}); - assert(runtime_config::GetSaveStatus("ui", "first").state == mod_settings::SaveState::Unavailable); + assert(runtime_config::GetSaveStatus("ui", "first", true).state == mod_settings::SaveState::Unavailable); + assert(runtime_config::GetSaveStatus("ui", "first", false).state == mod_settings::SaveState::SessionOnly); available = true; runtime_config::SaveSetting("ui", "first", true, {}); assert(runtime_config::HasSaveFailures()); // Retrying A cannot conceal unavailable B. - assert(runtime_config::GetSaveStatus("ui", "second").state == mod_settings::SaveState::Unavailable); + assert(runtime_config::GetSaveStatus("ui", "second", true).state == mod_settings::SaveState::Unavailable); runtime_config::SaveSetting("ui", "second", true, {}); assert(!runtime_config::HasSaveFailures()); Reset(); + runtime_config::SaveSetting("ui", "mode", std::string("warp"), {}); + assert(runtime_config::GetSaveStatus("ui", "mode", std::string("warp")).state == mod_settings::SaveState::Saved); + assert(runtime_config::GetSaveStatus("ui", "mode", std::string("jump")).state + == mod_settings::SaveState::SessionOnly); + fixture.work = true; + assert(runtime_config::GetSaveStatus("ui", "mode", std::string("warp")).state == mod_settings::SaveState::Pending); + assert(runtime_config::GetSaveStatus("ui", "mode", std::string("jump")).state + == mod_settings::SaveState::SessionOnly); + fixture.work = false; + fixture.failures = true; + assert(runtime_config::GetSaveStatus("ui", "mode", std::string("warp")).state == mod_settings::SaveState::Failed); + assert(runtime_config::GetSaveStatus("ui", "mode", std::string("jump")).state + == mod_settings::SaveState::SessionOnly); + Reset(); assert(runtime_config::RegisterSetting("ui", "auto_confirm_instant_warp")); assert(!runtime_config::RegisterSetting("", "invalid")); assert(WantsQuit([] { return true; })); diff --git a/tests/runtime_config_writer_test.cc b/tests/runtime_config_writer_test.cc index eece5d429..c3d84998b 100644 --- a/tests/runtime_config_writer_test.cc +++ b/tests/runtime_config_writer_test.cc @@ -102,11 +102,13 @@ int main() AwaitSave(); Check(writer.HasWork()); Check(writer.GetStatus("ui", "auto_confirm_instant_warp").pending); + Check(writer.GetStatus("ui", "auto_confirm_instant_warp").subject == Value{std::string("warp")}); const auto status_sequence = writer.StatusSequence(); // A duplicated definition never replaces the acknowledged comparison base. Check(writer.Register("ui", "auto_confirm_instant_warp", std::string("external"))); Check(writer.Submit("ui", "auto_confirm_instant_warp", std::string("jump")) == 2); Check(writer.Submit("ui", "auto_confirm_instant_warp", std::string("none")) == 3); + Check(writer.GetStatus("ui", "auto_confirm_instant_warp").subject == Value{std::string("none")}); writer.Stop(false); Check(!writer.PollStopped()); Check(writer.Submit("ui", "auto_confirm_instant_warp", std::string("warp")) == 0); @@ -127,6 +129,7 @@ int main() Check(writer.LastCompletion().outcome == Outcome::Saved); const auto status = writer.GetStatus("ui", "auto_confirm_instant_warp"); Check(status.revision == 3 && !status.pending && status.outcome == Outcome::Saved); + Check(status.subject == Value{std::string("none")}); // Older in-flight warp cannot label the latest value. Check(writer.StatusSequence() > status_sequence); } Begin(Outcome::Saved); @@ -150,6 +153,7 @@ int main() Check(requests.size() == 1); Check(writer.LastCompletion().revision == 2); Check(writer.LastCompletion().outcome == Outcome::Cancelled); + Check(writer.GetStatus("ui", "auto_confirm_instant_warp").subject == Value{std::string("jump")}); writer.Stop(true); } Begin(Outcome::IoError); @@ -178,6 +182,7 @@ int main() fail_thread_start = true; Check(!writer.Submit("ui", "auto_confirm_instant_warp", std::string("warp"))); Check(writer.HasFailure("ui", "auto_confirm_instant_warp") && writer.HasFailures() && !writer.HasWork()); + Check(writer.GetStatus("ui", "auto_confirm_instant_warp").subject == Value{std::string("warp")}); Check(!writer.HasFailure("other", "unregistered")); const auto retry = writer.Submit("ui", "auto_confirm_instant_warp", std::string("jump")); AwaitSave(); diff --git a/tests/settings_maintenance_test.cc b/tests/settings_maintenance_test.cc index eebc78390..717168762 100644 --- a/tests/settings_maintenance_test.cc +++ b/tests/settings_maintenance_test.cc @@ -36,7 +36,7 @@ int main() ++saves; boolean_save.state = SaveState::Pending; }; - boolean.save_status = [&] { return boolean_save; }; + boolean.save_status = [&](bool) { return boolean_save; }; BooleanSetting toggle(std::move(boolean)); ValueDefinition numeric{ "number", "Number", @@ -52,7 +52,7 @@ int main() ++saves; numeric_save.state = SaveState::Pending; }; - numeric.save_status = [&] { return numeric_save; }; + numeric.save_status = [&](float) { return numeric_save; }; ValueSetting canonical(numeric); SliderSetting slider(std::move(numeric), 0, 1, 0.01f, [] { return true; }); @@ -133,5 +133,9 @@ int main() auto* numeric_details = std::get(page->items[3]); numeric_details->invoke(0); assert(editor->commit("") && number == 0.5f && writes == 3 && saves == 4); + boolean_save.state = SaveState::SessionOnly; + assert(boolean_retry->Read(0).visible && boolean_retry->Read(0).button == "Save current value"); + boolean_retry->invoke(0); + assert(saves == 5 && writes == 3 && boolean_save.state == SaveState::Pending); std::cout << "Maintenance browse/dedup/reset/stale-session/per-key retry fixtures passed\n"; } diff --git a/tests/settings_metadata_test.cc b/tests/settings_metadata_test.cc index f2726a710..457afab0f 100644 --- a/tests/settings_metadata_test.cc +++ b/tests/settings_metadata_test.cc @@ -29,7 +29,7 @@ int main() assert(value == requested); disk = {SaveState::Pending, ++saves}; }; - definition.save_status = [&] { return disk; }; + definition.save_status = [&](bool) { return disk; }; definition.active_value = [&] { return std::optional(active); }; ValueSetting setting(std::move(definition)); assert(setting.metadata().TomlKey() == "ui.feature"); @@ -78,6 +78,8 @@ int main() ValueSetting second(std::move(throwing)); assert(second.SetFromUser(true, second.Observe()).outcome == Outcome::AppliedVerified); assert(second.save_status().state == SaveState::Unavailable); + value = false; // A later native change is not the value whose serializer failed. + assert(second.save_status().state == SaveState::None); int selected = 0; SaveStatus choiceDisk{SaveState::Pending, 1}; ValueDefinition choiceDefinition{"choice", "Mode", [&] { return ValueReadResult::Known(selected, 1); }, @@ -85,12 +87,12 @@ int main() selected = desired; return ApplyResult::Applied; }}; - choiceDefinition.save_status = [&] { return choiceDisk; }; + choiceDefinition.save_status = [&](int) { return choiceDisk; }; ChoiceSetting choice(std::move(choiceDefinition), {"First", "Second"}); NativeViewState styled(choice, 0); styled.Bind(); - for (const auto status : - {SaveState::Pending, SaveState::Saved, SaveState::Conflict, SaveState::Failed, SaveState::Unavailable}) { + for (const auto status : {SaveState::Pending, SaveState::Saved, SaveState::Conflict, SaveState::Failed, + SaveState::Unavailable, SaveState::SessionOnly}) { choiceDisk.state = status; const auto label = styled.presentation_label(); assert(label.find(choice.state().status_text()) != std::string::npos); diff --git a/tests/upstream_settings_fixture/spdlog/spdlog.h b/tests/upstream_settings_fixture/spdlog/spdlog.h index 91aabba11..651aba7a5 100644 --- a/tests/upstream_settings_fixture/spdlog/spdlog.h +++ b/tests/upstream_settings_fixture/spdlog/spdlog.h @@ -2,5 +2,6 @@ // Logging is an external boundary, not part of the catalog under test. namespace spdlog { +template void error(const char*, Args&&...) {} template void info(const char*, Args&&...) {} } // namespace spdlog diff --git a/tests/upstream_settings_test.cc b/tests/upstream_settings_test.cc index 15f606e10..abd9402bb 100644 --- a/tests/upstream_settings_test.cc +++ b/tests/upstream_settings_test.cc @@ -1,5 +1,5 @@ // Compile the actual catalog and shared warp/FT models. Only process/config, -// path, text-modal, logging, parser, and save boundaries are controlled here. +// path, text-modal, logging, and save boundaries are controlled here. #include "config.h" #include "defaultconfig.h" #include "file.h" @@ -8,6 +8,7 @@ #include "settings/settings_search.h" #include "settings/text_entry.h" #include "settings/upstream_settings.h" +#include "str_utils.h" #include #include @@ -36,7 +37,7 @@ std::vector writes; std::set registered; std::optional modal; bool labelsAvailable = true; -int labelRefreshes = 0, hudRefreshes = 0, shipRefreshes = 0, parseCalls = 0; +int labelRefreshes = 0, hudRefreshes = 0, shipRefreshes = 0; int queueTrackingResets = 0; float previousShipScale = 0, nextShipScale = 0; @@ -105,7 +106,9 @@ std::map> NativeReads() {"control.queue_enabled", [&cfg] { return V(cfg.queue_enabled); }}, {"control.select_timer", [&cfg] { return V(cfg.select_timer); }}, {"control.use_scopely_hotkeys", [&cfg] { return V(cfg.use_scopely_hotkeys); }}, +#if _WIN32 {"graphics.allow_cursor", [&cfg] { return V(cfg.allow_cursor); }}, +#endif #if _WIN32 {"graphics.borderless_fullscreen", [&cfg] { return V(cfg.borderless_fullscreen); }}, #endif @@ -129,7 +132,6 @@ std::map> NativeReads() {"graphics.system_zoom_preset_3", [&cfg] { return V(cfg.system_zoom_preset_3); }}, {"graphics.system_zoom_preset_4", [&cfg] { return V(cfg.system_zoom_preset_4); }}, {"graphics.system_zoom_preset_5", [&cfg] { return V(cfg.system_zoom_preset_5); }}, - {"graphics.transition_time", [&cfg] { return V(cfg.transition_time); }}, {"graphics.ui_scale", [&cfg] { return V(cfg.ui_scale); }}, {"graphics.ui_scale_ship", [&cfg] { return V(cfg.ui_scale_ship); }}, {"graphics.ui_scale_step", [&cfg] { return V(cfg.ui_scale_step); }}, @@ -167,7 +169,6 @@ std::map> NativeReads() {"ui.disable_move_keys", [&cfg] { return V(cfg.disable_move_keys); }}, {"ui.disable_preview_locate", [&cfg] { return V(cfg.disable_preview_locate); }}, {"ui.disable_preview_recall", [&cfg] { return V(cfg.disable_preview_recall); }}, - {"ui.disable_toast_banners", [&cfg] { return V(cfg.disable_toast_banners); }}, {"ui.disable_veil_chat", [&cfg] { return V(cfg.disable_veil_chat); }}, {"ui.disabled_banner_types", [&cfg] { return V(FormatBannerFilter(cfg.disabled_banner_types)); }}, {"ui.dock_syncmanagement", [&cfg] { return V(cfg.dock_syncmanagement); }}, @@ -197,14 +198,18 @@ std::map> NativeReads() return V(std::string( std::array{"auto", "always", "never"}.at(static_cast(cfg.MissionHudButtonVisibility("q_trials"))))); }}, +#if defined(_WIN32) && defined(_M_X64) {"ui.instant_cargo_counter", [&cfg] { return V(cfg.instant_cargo_counter); }}, +#endif {"ui.instant_warp_always_ask", [&cfg] { return V(cfg.instant_warp_always_ask_all ? std::string("*") : Join(cfg.instant_warp_always_ask)); }}, {"ui.instant_warp_auto_jump", [&cfg] { return V(cfg.instant_warp_auto_jump_all ? std::string("*") : Join(cfg.instant_warp_auto_jump)); }}, {"ui.instant_warp_auto_warp", [&cfg] { return V(cfg.instant_warp_auto_warp_all ? std::string("*") : Join(cfg.instant_warp_auto_warp)); }}, +#if _WIN32 {"ui.notify_banner_types", [&cfg] { return V(FormatBannerFilter(cfg.notify_banner_types)); }}, +#endif {"ui.officer_sort", [&cfg] { return V(cfg.officer_sort); }}, {"ui.only_show_first_popup", [&cfg] { return V(cfg.only_show_first_popup); }}, {"ui.pinned_ships", [&cfg] { return V(Join(cfg.pinned_ships)); }}, @@ -496,8 +501,15 @@ bool RegisterSetting(const char* section, const char* key) noexcept void SaveSetting(const char* section, const char* key, config_edit::Value value, std::chrono::milliseconds delay) noexcept { fixture::writes.push_back({std::string(section) + "." + key, std::move(value), delay}); } -mod_settings::SaveStatus GetSaveStatus(const char*, const char*) noexcept -{ return {mod_settings::SaveState::Saved, fixture::writes.size()}; } +mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key, const config_edit::Value& current) noexcept +{ + const auto name = std::string(section) + "." + key; + for (auto it = fixture::writes.rbegin(); it != fixture::writes.rend(); ++it) + if (it->key == name) + return {it->value == current ? mod_settings::SaveState::Saved : mod_settings::SaveState::SessionOnly, + fixture::writes.size()}; + return {}; +} } // namespace runtime_config namespace mod_settings { @@ -555,63 +567,6 @@ std::optional notification_sound_from_name(std::string_view n } void notification_audio_play(NotificationSound) {} -// Controlled parser boundaries prove the catalog delegates validation and uses -// normalized readback. Startup parser correctness is a separate responsibility. -bool ParseShipFilter(std::string_view input, std::vector& names, bool& all) -{ - ++fixture::parseCalls; - names.clear(); - all = false; - const auto text = fixture::Trim(input); - if (text == "*") { - all = true; - return true; - } - if (text.find('*') != text.npos) - return false; - std::istringstream stream(text); - std::string name; - while (std::getline(stream, name, ',')) { - name = fixture::Trim(name); - if (name.empty()) - continue; - for (auto& c : name) - c = static_cast(std::toupper(static_cast(c))); - names.push_back(name); - } - return true; -} -bool ParseFactionFilter(std::string_view input, std::vector& names) -{ - ++fixture::parseCalls; - names.clear(); - std::istringstream stream{std::string(input)}; - std::string name; - while (std::getline(stream, name, ',')) { - name = fixture::Lower(fixture::Trim(name)); - if (name.empty()) - continue; - if (name != "federation" && name != "klingon" && name != "romulan") - return false; - names.push_back(name); - } - return true; -} -bool ParseBannerFilter(std::string_view input, std::vector& values) -{ - ++fixture::parseCalls; - values.clear(); - const auto name = fixture::Lower(fixture::Trim(input)); - if (name.empty()) - return true; - if (name != "victory") - return false; - values.push_back(1); - return true; -} -std::string FormatBannerFilter(const std::vector& values) -{ return values.empty() ? "" : "Victory"; } - int main(int argc, char** argv) { using namespace fixture; @@ -632,9 +587,9 @@ int main(int argc, char** argv) Require(expected.emplace(row.at("key").get(), row).second, "duplicate inventory key"); } #if _WIN32 - Require(expected.size() == 89, "Windows catalog count changed without a coverage decision"); + Require(expected.size() == 87, "Windows catalog count changed without a coverage decision"); #else - Require(expected.size() == 87, "macOS catalog count changed without a coverage decision"); + Require(expected.size() == 82, "macOS catalog count changed without a coverage decision"); #endif const auto reads = NativeReads(); Require(reads.size() == expected.size(), "native field map missing an inventory key"); @@ -716,7 +671,104 @@ int main(int argc, char** argv) models.at("control.queue_enabled").reset(); models.at("control.kirshara_fixes").reset(); Require(queueTrackingResets == 4, "unchanged queue defaults unnecessarily invalidated recovery tracking"); - Require(labelRefreshes > 0 && hudRefreshes > 0 && parseCalls > 0, "native refresh/parser boundaries were bypassed"); + Require(labelRefreshes > 0 && hudRefreshes > 0, "native refresh/parser boundaries were bypassed"); + + // Exercise actual dependency combinations rather than assuming a catalog + // control's owner from its name or its Config field. + auto withFlags = [&](std::initializer_list> flags, auto check) { + std::vector> originals; + for (const auto& [member, value] : flags) { + originals.emplace_back(member, Config::Get().*member); + Config::Get().*member = value; + } + check(); + for (const auto& [member, value] : originals) + Config::Get().*member = value; + }; + withFlags({{&Config::installMiscPatches, false}, {&Config::installTempCrashFixes, true}}, [&] { + Require(models.at("ui.only_show_first_popup").available() + && models.at("ui.always_skip_reveal_sequence").available(), + "temporary hooks wrongly depend on Misc"); + }); + withFlags({{&Config::installMiscPatches, true}, {&Config::installTempCrashFixes, false}}, [&] { + Require(!models.at("ui.only_show_first_popup").available() + && !models.at("ui.always_skip_reveal_sequence").available(), + "disabled temporary hooks advertised"); + }); + withFlags({{&Config::installPinnedShipSortHooks, false}}, [&] { + Require(!models.at("ui.pinned_ships").available(), "disabled sort editor advertised"); + writes.clear(); + Require(PersistCurrentSetting("ui", "pinned_ships") && writes.size() == 1 + && writes.back().key == "ui.pinned_ships", + "native pin save incorrectly depends on sort/UI"); + }); + withFlags( + {{&Config::installHotkeyHooks, false}, {&Config::installZoomHooks, true}, {&Config::installPanHooks, true}}, + [&] { + Require(models.at("control.hotkeys_enabled").available() && models.at("control.hotkeys_extended").available() + && models.at("control.use_scopely_hotkeys").available() + && models.at("ui.disable_move_keys").available(), + "independent camera owners ignored"); + }); + withFlags({{&Config::installPanHooks, false}}, + [&] { Require(!models.at("ui.disable_move_keys").available(), "disabled pan owner advertised"); }); + withFlags({{&Config::installLoadingScreenHooks, false}, {&Config::installTransitionScreenHooks, true}}, [&] { + Require(models.at("graphics.loader_image").available() && models.at("graphics.loader_logo_scale").available(), + "shared transition assets incorrectly depend on loading hooks"); + }); + withFlags({{&Config::installHotkeyHooks, false}, + {&Config::installQueueAddressGuardHooks, false}, + {&Config::installActionQueueRecoveryHooks, false}, + {&Config::installThinQueueProtectionHooks, false}, + {&Config::installTestPatches, true}}, + [&] { + Require(models.at("control.queue_enabled").available() + && !models.at("control.kirshara_fixes").available(), + "queue/protection owners conflated"); + }); + + // The same production parsers serve startup and the exact editor. + const std::string bounded = "AbC suffix"; + Require(AsciiStrToUpper(std::string_view(bounded).substr(0, 3)) == "ABC" + && AsciiStrToLower(std::string_view(bounded).substr(0, 3)) == "abc", + "case helpers ignored view bounds"); + auto edit = [&](const std::string& key, const std::string& text) { + models.at(key).exact->invoke(0); + Require(modal && modal->validate(text).empty() && modal->commit(text), "production parser rejected " + text); + modal.reset(); + }; + edit("ui.daily_bulk_claim_factions", " federation ,klingon ,romulan "); + Require(Config::Get().daily_bulk_claim_factions == std::vector({"federation", "klingon", "romulan"}), + "faction whitespace normalization failed"); + models.at("ui.daily_bulk_claim_factions").reset(); + edit("ui.disabled_banner_types", "VICTORY ,DEFEAT "); + Require(FormatBannerFilter(Config::Get().disabled_banner_types) == "Victory, Defeat", "banner parsing failed"); + models.at("ui.disabled_banner_types").reset(); + edit("ui.instant_warp_auto_jump", " * "); + Require(Config::Get().instant_warp_auto_jump_all, "trimmed wildcard was not recognized"); + models.at("ui.instant_warp_auto_jump").reset(); + edit("ui.extend_chest_purchase_max", "0"); + Require(Config::Get().extend_chest_purchase_max == DefaultConfig::UI::extend_chest_purchase_max, + "restart chest setting changed active hook behavior"); + Require(std::get(writes.back().value) == 0, "documented disabled value was not saved"); + models.at("ui.extend_chest_purchase_max").reset(); + edit("graphics.system_zoom_preset_1", "1000"); + Require(models.at("graphics.system_zoom_preset_1").exact->status() == "Applied and saved", "preset save missing"); + Config::Get().system_zoom_preset_1 = 2000; // Same mutation made by the explicit StoreZoom shortcut. + Require(models.at("graphics.system_zoom_preset_1").exact->status() == "Applied for this session; not saved", + "old saved badge described a different preset"); + Require(PersistCurrentSetting("graphics", "system_zoom_preset_1") && std::get(writes.back().value) == 2000, + "explicit preset save bypassed its canonical serializer"); + Require(models.at("graphics.system_zoom_preset_1").exact->status() == "Applied and saved", "preset status stale"); + models.at("graphics.system_zoom_preset_1").reset(); + edit("graphics.default_system_zoom", "1000"); + const auto beforeAutomatic = writes.size(); + Config::Get().default_system_zoom = 2000; // Automatic use-presets-as-default remains session-only. + Require(models.at("graphics.default_system_zoom").exact->status() == "Applied for this session; not saved" + && writes.size() == beforeAutomatic, + "automatic session default was saved or mislabeled"); + models.at("graphics.default_system_zoom").reset(); + Require(ReadAll(reads) == defaults, "parser/restart regression did not restore baseline"); auto invalid = [&](const std::string& key, const std::string& text) { auto* action = models.at(key).exact; From 2cac8c21c8380a221bf576338be45b19deefbf46 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 04:42:03 -0500 Subject: [PATCH 06/17] Clarify when session-only save feedback is available --- docs/MOD_SETTINGS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md index a2e075ce0..9e5e53ce0 100644 --- a/docs/MOD_SETTINGS.md +++ b/docs/MOD_SETTINGS.md @@ -8,7 +8,7 @@ The editor uses the same configuration values and defaults as the mod's TOML con Ordinary controls apply to the current session and save automatically. Each control reports its own save state. A failed save or an external edit conflict remains visible; another successful save does not clear it. Resolve a conflicting external edit before retrying. Unrelated TOML values and comments are preserved. -Save feedback describes the current value. A later session-only change cannot retain an older Saved badge. Explicit save-preset/default shortcuts persist their zoom values; automatically using the last preset as the session default remains session-only. Defaults & Help offers Save current value for an unsaved session change. +Save feedback describes the current value. A later session-only change cannot retain an older Saved badge. Explicit save-preset/default shortcuts persist their zoom values; automatically using the last preset as the session default remains session-only. After a save attempt in the current session, Defaults & Help offers Save current value for a later session-only change. Slider and scale-shortcut changes apply immediately and save after 250 ms without another change to that value. Orderly exit flushes pending changes. If the game vetoes a quit request, saving resumes for the continuing session. A failure to write the generated runtime-vars diagnostic does not prevent startup with a readable configuration. From c7f6974424a4e52e46483630a4ad1f1f0683fb69 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 20:24:47 -0500 Subject: [PATCH 07/17] Hook the shared player-title implementation instead of bind thunks --- mods/src/patches/parts/chat_titles.cc | 65 +++++++-------------------- 1 file changed, 17 insertions(+), 48 deletions(-) diff --git a/mods/src/patches/parts/chat_titles.cc b/mods/src/patches/parts/chat_titles.cc index b472d5338..0cbf0e75f 100644 --- a/mods/src/patches/parts/chat_titles.cc +++ b/mods/src/patches/parts/chat_titles.cc @@ -85,7 +85,7 @@ void UserProfileWidget_UpdatePlayerTitle_Hook(auto original, void* _this) } catch (...) { } } -void PlayerTitleWidget_BindPath_Hook(auto original, void* _this) +void PlayerTitleWidget_SetIdentifiers_Hook(auto original, void* _this) { original(_this); try { @@ -146,75 +146,44 @@ void InstallChatTitleDisplayHooks() ErrorMsg::MissingMethod("UserProfileWidget", "UpdatePlayerTitle"); } - void* set_widget_data = nullptr; - void* on_did_bind_context = nullptr; + void* set_identifiers = nullptr; if (auto title_widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PlayerAvatars", "PlayerTitleWidget"); !title_widget.isValidHelper()) { ErrorMsg::MissingHelper("Digit.Prime.PlayerAvatars", "PlayerTitleWidget"); - } else { - set_widget_data = title_widget.GetMethod("SetWidgetData", 0); - on_did_bind_context = title_widget.GetMethod("OnDidBindContext", 0); - if (!set_widget_data || !on_did_bind_context) { - ErrorMsg::MissingMethod("PlayerTitleWidget", set_widget_data ? "OnDidBindContext" : "SetWidgetData"); - set_widget_data = nullptr; - on_did_bind_context = nullptr; - } - } - -#if _WIN32 - // These small IL2CPP bind wrappers crash independently on the Windows - // client when detoured. Keep the verified-safe UpdatePlayerTitle hook and - // preserve the native bind paths. - if (set_widget_data || on_did_bind_context) { - spdlog::warn("[ChatTitleDisplay] skipping unsafe PlayerTitleWidget bind wrappers on Windows"); - set_widget_data = nullptr; - on_did_bind_context = nullptr; + } else if ((set_identifiers = title_widget.GetMethod("SetIdentifiers", 0)) == nullptr) { + ErrorMsg::MissingMethod("PlayerTitleWidget", "SetIdentifiers"); } -#endif - if (!update_player_title && !set_widget_data && !on_did_bind_context) { + if (!update_player_title && !set_identifiers) { return; } if (update_player_title) { update_player_title = ValidateDetourTarget(update_player_title, "UpdatePlayerTitle"); } - if (set_widget_data) { - set_widget_data = ValidateDetourTarget(set_widget_data, "SetWidgetData"); - } - if (on_did_bind_context) { - on_did_bind_context = ValidateDetourTarget(on_did_bind_context, "OnDidBindContext"); + if (set_identifiers) { + set_identifiers = ValidateDetourTarget(set_identifiers, "SetIdentifiers"); } - if (!update_player_title && !set_widget_data && !on_did_bind_context) { + if (!update_player_title && !set_identifiers) { spdlog::warn("Skipping ChatTitleDisplayHooks: no detour target fits; no hooks installed."); return; } - // The PlayerTitleWidget callbacks can resolve through thunks to one shared - // native implementation. A second detour on the same address would rebuild - // the first detour's jump into a broken trampoline, so keep one hook per - // target; the shared body funnels both method entries into the same hook. - if (set_widget_data && set_widget_data == update_player_title) { - spdlog::info("[ChatTitleDisplay] SetWidgetData shares its native implementation with UpdatePlayerTitle; " - "keeping one detour"); - set_widget_data = nullptr; - } - if (on_did_bind_context - && (on_did_bind_context == update_player_title || on_did_bind_context == set_widget_data)) { - spdlog::info("[ChatTitleDisplay] OnDidBindContext shares its native implementation with another target; " - "keeping one detour"); - on_did_bind_context = nullptr; + // Both title-bind callbacks tail-call SetIdentifiers. On Windows client 272 + // their shared wrapper is only 16 bytes, less than SPUD's 24-byte overwrite, + // so detouring it also corrupts the adjacent title-clear callback. Hook the + // substantive implementation once instead. + if (set_identifiers && set_identifiers == update_player_title) { + spdlog::warn("[ChatTitleDisplay] SetIdentifiers shares its target with UpdatePlayerTitle; keeping one detour"); + set_identifiers = nullptr; } if (update_player_title) { SPUD_STATIC_DETOUR(update_player_title, UserProfileWidget_UpdatePlayerTitle_Hook); } - if (set_widget_data) { - SPUD_STATIC_DETOUR(set_widget_data, PlayerTitleWidget_BindPath_Hook); - } - if (on_did_bind_context) { - SPUD_STATIC_DETOUR(on_did_bind_context, PlayerTitleWidget_BindPath_Hook); + if (set_identifiers) { + SPUD_STATIC_DETOUR(set_identifiers, PlayerTitleWidget_SetIdentifiers_Hook); } spdlog::info("Chat title display: chat player title rows {}", Config::Get().show_player_title ? "shown" : "hidden"); From 1efac2a1043e022fd8d24addfe174124397940cd Mon Sep 17 00:00:00 2001 From: netniV Date: Sat, 17 Oct 2026 18:07:00 +0100 Subject: [PATCH 08/17] Fix issue with PC player titles --- mods/src/patches/parts/chat_titles.cc | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/mods/src/patches/parts/chat_titles.cc b/mods/src/patches/parts/chat_titles.cc index 6ab4ead46..b472d5338 100644 --- a/mods/src/patches/parts/chat_titles.cc +++ b/mods/src/patches/parts/chat_titles.cc @@ -47,15 +47,13 @@ bool IsChatRow(void* profile_widget) } return false; } -void ClearTitleRow(void* widget) +void HideTitleRow(void* title) { - auto* method = - il2cpp_class_get_method_from_name(il2cpp_object_get_class((Il2CppObject*)widget), "ClearPlayerTitleWidget", 0); - if (!method) { + auto* game_object = reinterpret_cast(InvokeSelf(static_cast(title), "get_gameObject")); + if (!game_object) { return; } - Il2CppException* exception = nullptr; - il2cpp_runtime_invoke(method, widget, nullptr, &exception); + game_object->SetActive(false); } void ApplyTitleDisplay(void* profile_widget) @@ -75,7 +73,7 @@ void ApplyTitleDisplay(void* profile_widget) return; } - ClearTitleRow(widget); + HideTitleRow(title); } } // namespace @@ -163,6 +161,17 @@ void InstallChatTitleDisplayHooks() } } +#if _WIN32 + // These small IL2CPP bind wrappers crash independently on the Windows + // client when detoured. Keep the verified-safe UpdatePlayerTitle hook and + // preserve the native bind paths. + if (set_widget_data || on_did_bind_context) { + spdlog::warn("[ChatTitleDisplay] skipping unsafe PlayerTitleWidget bind wrappers on Windows"); + set_widget_data = nullptr; + on_did_bind_context = nullptr; + } +#endif + if (!update_player_title && !set_widget_data && !on_did_bind_context) { return; } From f2c6ced2e41493f7fd42cc1c3b157cf3c9958a79 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 20:24:47 -0500 Subject: [PATCH 09/17] Hook the shared player-title implementation instead of bind thunks --- mods/src/patches/parts/chat_titles.cc | 65 +++++++-------------------- 1 file changed, 17 insertions(+), 48 deletions(-) diff --git a/mods/src/patches/parts/chat_titles.cc b/mods/src/patches/parts/chat_titles.cc index b472d5338..0cbf0e75f 100644 --- a/mods/src/patches/parts/chat_titles.cc +++ b/mods/src/patches/parts/chat_titles.cc @@ -85,7 +85,7 @@ void UserProfileWidget_UpdatePlayerTitle_Hook(auto original, void* _this) } catch (...) { } } -void PlayerTitleWidget_BindPath_Hook(auto original, void* _this) +void PlayerTitleWidget_SetIdentifiers_Hook(auto original, void* _this) { original(_this); try { @@ -146,75 +146,44 @@ void InstallChatTitleDisplayHooks() ErrorMsg::MissingMethod("UserProfileWidget", "UpdatePlayerTitle"); } - void* set_widget_data = nullptr; - void* on_did_bind_context = nullptr; + void* set_identifiers = nullptr; if (auto title_widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PlayerAvatars", "PlayerTitleWidget"); !title_widget.isValidHelper()) { ErrorMsg::MissingHelper("Digit.Prime.PlayerAvatars", "PlayerTitleWidget"); - } else { - set_widget_data = title_widget.GetMethod("SetWidgetData", 0); - on_did_bind_context = title_widget.GetMethod("OnDidBindContext", 0); - if (!set_widget_data || !on_did_bind_context) { - ErrorMsg::MissingMethod("PlayerTitleWidget", set_widget_data ? "OnDidBindContext" : "SetWidgetData"); - set_widget_data = nullptr; - on_did_bind_context = nullptr; - } - } - -#if _WIN32 - // These small IL2CPP bind wrappers crash independently on the Windows - // client when detoured. Keep the verified-safe UpdatePlayerTitle hook and - // preserve the native bind paths. - if (set_widget_data || on_did_bind_context) { - spdlog::warn("[ChatTitleDisplay] skipping unsafe PlayerTitleWidget bind wrappers on Windows"); - set_widget_data = nullptr; - on_did_bind_context = nullptr; + } else if ((set_identifiers = title_widget.GetMethod("SetIdentifiers", 0)) == nullptr) { + ErrorMsg::MissingMethod("PlayerTitleWidget", "SetIdentifiers"); } -#endif - if (!update_player_title && !set_widget_data && !on_did_bind_context) { + if (!update_player_title && !set_identifiers) { return; } if (update_player_title) { update_player_title = ValidateDetourTarget(update_player_title, "UpdatePlayerTitle"); } - if (set_widget_data) { - set_widget_data = ValidateDetourTarget(set_widget_data, "SetWidgetData"); - } - if (on_did_bind_context) { - on_did_bind_context = ValidateDetourTarget(on_did_bind_context, "OnDidBindContext"); + if (set_identifiers) { + set_identifiers = ValidateDetourTarget(set_identifiers, "SetIdentifiers"); } - if (!update_player_title && !set_widget_data && !on_did_bind_context) { + if (!update_player_title && !set_identifiers) { spdlog::warn("Skipping ChatTitleDisplayHooks: no detour target fits; no hooks installed."); return; } - // The PlayerTitleWidget callbacks can resolve through thunks to one shared - // native implementation. A second detour on the same address would rebuild - // the first detour's jump into a broken trampoline, so keep one hook per - // target; the shared body funnels both method entries into the same hook. - if (set_widget_data && set_widget_data == update_player_title) { - spdlog::info("[ChatTitleDisplay] SetWidgetData shares its native implementation with UpdatePlayerTitle; " - "keeping one detour"); - set_widget_data = nullptr; - } - if (on_did_bind_context - && (on_did_bind_context == update_player_title || on_did_bind_context == set_widget_data)) { - spdlog::info("[ChatTitleDisplay] OnDidBindContext shares its native implementation with another target; " - "keeping one detour"); - on_did_bind_context = nullptr; + // Both title-bind callbacks tail-call SetIdentifiers. On Windows client 272 + // their shared wrapper is only 16 bytes, less than SPUD's 24-byte overwrite, + // so detouring it also corrupts the adjacent title-clear callback. Hook the + // substantive implementation once instead. + if (set_identifiers && set_identifiers == update_player_title) { + spdlog::warn("[ChatTitleDisplay] SetIdentifiers shares its target with UpdatePlayerTitle; keeping one detour"); + set_identifiers = nullptr; } if (update_player_title) { SPUD_STATIC_DETOUR(update_player_title, UserProfileWidget_UpdatePlayerTitle_Hook); } - if (set_widget_data) { - SPUD_STATIC_DETOUR(set_widget_data, PlayerTitleWidget_BindPath_Hook); - } - if (on_did_bind_context) { - SPUD_STATIC_DETOUR(on_did_bind_context, PlayerTitleWidget_BindPath_Hook); + if (set_identifiers) { + SPUD_STATIC_DETOUR(set_identifiers, PlayerTitleWidget_SetIdentifiers_Hook); } spdlog::info("Chat title display: chat player title rows {}", Config::Get().show_player_title ? "shown" : "hidden"); From 56a549112c66404cf1a93e2c59f872128194ca0c Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 23:05:00 -0500 Subject: [PATCH 10/17] Improve choice titles, inline numeric editing, image picking and reset labels --- docs/MOD_SETTINGS.md | 4 +- mods/src/image_file_picker_mac.mm | 27 +++ mods/src/image_file_picker_windows.cc | 57 +++++ mods/src/settings/choice_setting.h | 2 + mods/src/settings/image_file_picker.h | 6 + mods/src/settings/image_file_setting.h | 97 ++++++++ mods/src/settings/maintenance.h | 10 +- mods/src/settings/native/action_widgets.cc | 18 ++ mods/src/settings/native/action_widgets.h | 2 + mods/src/settings/native/page_navigation.cc | 36 ++- mods/src/settings/native/page_navigation.h | 2 + .../src/settings/native/value_widget_record.h | 5 + mods/src/settings/native/value_widgets.cc | 217 +++++++++++++++++- mods/src/settings/native/value_widgets.h | 1 + mods/src/settings/slider_setting.h | 2 + mods/src/settings/upstream_settings.cc | 43 +++- tests/image_file_setting_test.cc | 76 ++++++ tests/upstream_settings_test.cc | 14 ++ 18 files changed, 587 insertions(+), 32 deletions(-) create mode 100644 mods/src/image_file_picker_mac.mm create mode 100644 mods/src/image_file_picker_windows.cc create mode 100644 mods/src/settings/image_file_picker.h create mode 100644 mods/src/settings/image_file_setting.h create mode 100644 tests/image_file_setting_test.cc diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md index 70d480283..bd602c9bd 100644 --- a/docs/MOD_SETTINGS.md +++ b/docs/MOD_SETTINGS.md @@ -18,11 +18,11 @@ Open a page's **Defaults & Help** to read setting details, restore one default, ## Numbers and lists -Use **Edit** for an exact numeric value. Convenience sliders have useful ranges; an existing custom value outside that range remains visible in the exact editor and is preserved until explicitly changed. Numeric entries reject invalid syntax, non-finite numbers, and values outside a setting's valid domain. Integer controls require whole numbers. Units and setting-specific limits appear with the control. +Single-choice checkbox groups show their setting title above the options. Use **Edit** to the right of a slider for an exact numeric value; numeric controls without a slider retain an Edit button. Convenience sliders have useful ranges; an existing custom value outside that range remains visible in the exact editor and is preserved until explicitly changed. Numeric entries reject invalid syntax, non-finite numbers, and values outside a setting's valid domain. Integer controls require whole numbers. Units and setting-specific limits appear with the control. Ship filter lists accept comma-separated names using the same matching rules as TOML. Instant warp filters accept `*`; pinned ships require names. Faction and banner lists validate names against the startup parser rather than silently discarding unknown entries. Clear a list explicitly to remove its entries. -The loading image control accepts a local file path. Empty selects the embedded fallback. Selecting a path does not copy, move, or modify the image file, and the loading asset changes require restart. +Use **Choose file** on the loading image control to select a PNG or JPEG. Cancel leaves the current path unchanged. Reset its default in **Defaults & Help** to use the embedded fallback. Selecting a path does not copy, move, or modify the image file, and the loading asset changes require restart. ## Shortcuts diff --git a/mods/src/image_file_picker_mac.mm b/mods/src/image_file_picker_mac.mm new file mode 100644 index 000000000..ebe94e834 --- /dev/null +++ b/mods/src/image_file_picker_mac.mm @@ -0,0 +1,27 @@ +#ifdef __APPLE__ +#include "settings/image_file_picker.h" +#import +#import +#include + +std::future OpenImageFilePicker() +{ + auto result = std::make_shared>(); + auto future = result->get_future(); + dispatch_async(dispatch_get_main_queue(), ^{ + @autoreleasepool { + NSOpenPanel* panel = [NSOpenPanel openPanel]; + [panel setTitle:@"Choose a loading image"]; + [panel setCanChooseFiles:YES]; + [panel setCanChooseDirectories:NO]; + [panel setAllowsMultipleSelection:NO]; + [panel setAllowedFileTypes:@[@"png", @"jpg", @"jpeg"]]; + [panel beginWithCompletionHandler:^(NSModalResponse response) { + const char* path = response == NSModalResponseOK ? [[[panel URL] path] UTF8String] : nullptr; + result->set_value(path ? std::string(path) : std::string{}); + }]; + } + }); + return future; +} +#endif diff --git a/mods/src/image_file_picker_windows.cc b/mods/src/image_file_picker_windows.cc new file mode 100644 index 000000000..77bad1fe3 --- /dev/null +++ b/mods/src/image_file_picker_windows.cc @@ -0,0 +1,57 @@ +#if _WIN32 +#include "settings/image_file_picker.h" +#include +#include +#include +#include +#include +#include +#include +#include + +std::future OpenImageFilePicker() +{ + std::packaged_task task([]() -> std::string { + struct Apartment { + HRESULT result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); + ~Apartment() + { + if (SUCCEEDED(result)) + CoUninitialize(); + } + } apartment; + auto check = [](HRESULT result) { + if (FAILED(result)) + throw std::runtime_error("Cannot open the file picker"); + }; + check(apartment.result); + Microsoft::WRL::ComPtr dialog; + check(CoCreateInstance(__uuidof(FileOpenDialog), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&dialog))); + DWORD options = 0; + check(dialog->GetOptions(&options)); + check(dialog->SetOptions(options | FOS_FORCEFILESYSTEM | FOS_FILEMUSTEXIST | FOS_PATHMUSTEXIST | FOS_NOCHANGEDIR)); + const COMDLG_FILTERSPEC filter{L"Images (*.png; *.jpg; *.jpeg)", L"*.png;*.jpg;*.jpeg"}; + check(dialog->SetFileTypes(1, &filter)); + check(dialog->SetTitle(L"Choose a loading image")); + // Unowned: a modal dialog owned by the Unity window would disable game input. + const auto result = dialog->Show(nullptr); + if (result == HRESULT_FROM_WIN32(ERROR_CANCELLED)) + return {}; + check(result); + Microsoft::WRL::ComPtr item; + check(dialog->GetResult(&item)); + PWSTR raw = nullptr; + check(item->GetDisplayName(SIGDN_FILESYSPATH, &raw)); + struct PathMemory { + PWSTR value; + ~PathMemory() + { CoTaskMemFree(value); } + } memory{raw}; + const auto utf8 = std::filesystem::path(raw).u8string(); + return std::string(reinterpret_cast(utf8.data()), utf8.size()); + }); + auto result = task.get_future(); + std::thread(std::move(task)).detach(); + return result; +} +#endif diff --git a/mods/src/settings/choice_setting.h b/mods/src/settings/choice_setting.h index ea108538e..a7bb04265 100644 --- a/mods/src/settings/choice_setting.h +++ b/mods/src/settings/choice_setting.h @@ -19,6 +19,8 @@ class ChoiceSetting { return state_; } const std::vector& labels() const { return labels_; } + std::string title_id() const + { return state_.id() + ".title"; } std::string item_id(int index) const { return state_.id() + "." + std::to_string(index); } diff --git a/mods/src/settings/image_file_picker.h b/mods/src/settings/image_file_picker.h new file mode 100644 index 000000000..9a5584a34 --- /dev/null +++ b/mods/src/settings/image_file_picker.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include + +// Absolute UTF-8 path, empty on cancel, exception on failure. Never blocks the game thread. +std::future OpenImageFilePicker(); diff --git a/mods/src/settings/image_file_setting.h b/mods/src/settings/image_file_setting.h new file mode 100644 index 000000000..7b2dfe6d0 --- /dev/null +++ b/mods/src/settings/image_file_setting.h @@ -0,0 +1,97 @@ +#pragma once +#include "action_setting.h" +#include "image_file_picker.h" +#include "value_settings.h" +#include +#include +#include +#include +#include + +namespace mod_settings +{ +// The UI thread owns the setting and polls a picker that owns only its result. +// Reset also cancels pending intent when the current value already is default. +class ImageFileSetting +{ +public: + ImageFileSetting(std::shared_ptr> setting, ActionSetting& action) + : setting_(std::move(setting)) + { + const auto read = action.read; + action.read = [this, read](std::size_t index) { + auto result = read(index); + result.button = "Choose file"; + result.enabled = result.enabled && !picker_.valid(); + if (!status_.empty()) + result.value += "\n" + status_; + return result; + }; + action.invoke = [this](std::size_t) { + if (picker_.valid()) + return; + observed_ = setting_->Observe(); + dirty_ = true; + if (!observed_.state.known()) + return; + try { + picker_ = OpenImageFilePicker(); + requested_ = generation_; + status_ = "Choosing an image..."; + } catch (...) { + status_ = "Could not open the file picker"; + } + }; + const auto reset = action.reset; + action.reset = [this, reset] { + ++generation_; + reset(); + }; + const auto prepare = action.prepare_reset; + action.prepare_reset = [this, prepare] { + auto plan = prepare(); + const auto apply = plan.apply; + plan.apply = [this, apply] { + ++generation_; + return apply && apply(); + }; + return plan; + }; + } + bool Poll() + { + const bool changed = std::exchange(dirty_, false); + if (!picker_.valid() || picker_.wait_for(std::chrono::seconds(0)) != std::future_status::ready) + return changed; + try { + const auto path = picker_.get(); + if (path.empty()) + status_ = "Selection canceled"; + else if (requested_ != generation_) + status_ = "Selection discarded after reset"; + else { + std::error_code error; + if (!std::filesystem::is_regular_file(std::filesystem::u8path(path), error) || error) + status_ = "Selected image is unavailable"; + else { + const auto result = setting_->SetFromUser(path, observed_).outcome; + status_ = result == Outcome::AppliedVerified || result == Outcome::Unchanged + ? std::string{} + : "Setting changed; choose the image again"; + } + } + } catch (...) { + status_ = "Could not select an image"; + } + return true; + } + +private: + std::shared_ptr> setting_; + ValueSnapshot observed_; + std::future picker_; + std::string status_; + bool dirty_ = false; + std::uint64_t generation_ = 0, requested_ = 0; +}; +} // namespace mod_settings diff --git a/mods/src/settings/maintenance.h b/mods/src/settings/maintenance.h index 6a5eed733..cbfb67566 100644 --- a/mods/src/settings/maintenance.h +++ b/mods/src/settings/maintenance.h @@ -88,10 +88,8 @@ inline void RegisterMaintenancePages(PageCatalog& catalog) }; auto all = std::make_shared(); all->identity = id + ".reset"; - all->label = "Restore page defaults"; - all->read = [label = page.label](std::size_t) { - return ActionSetting::Presentation{"Restore page defaults", "Review", label}; - }; + all->label = "Defaults"; + all->read = [label = page.label](std::size_t) { return ActionSetting::Presentation{"Defaults", "Reset", label}; }; all->invoke = [controls, label = page.label](std::size_t) { std::vector plans; std::size_t skipped = 0; @@ -123,7 +121,7 @@ inline void RegisterMaintenancePages(PageCatalog& catalog) return true; }, .read_only = true, - .apply_label = "Reset page"}); + .apply_label = "Reset"}); }; add(std::move(all)); for (const auto& control : *controls) { @@ -151,7 +149,7 @@ inline void RegisterMaintenancePages(PageCatalog& catalog) .commit = [plan = std::move(plan)]( std::string_view) { return plan.current && plan.current() && plan.apply && plan.apply(); }, .read_only = true, - .apply_label = "Reset default"}); + .apply_label = "Reset"}); }; add(std::move(details)); auto retry = std::make_shared(); diff --git a/mods/src/settings/native/action_widgets.cc b/mods/src/settings/native/action_widgets.cc index 5a1c987e1..a31758cbc 100644 --- a/mods/src/settings/native/action_widgets.cc +++ b/mods/src/settings/native/action_widgets.cc @@ -158,6 +158,24 @@ void RenderAction(ActionView& view) void* args[] = {&enabled}; Call(Target(view.button), "OverrideInteractable", 1, args); } +void InvokeSliderEditor(Il2CppObject* widget, Il2CppObject* context, SliderSetting& slider) +{ + if (!OnUIThread() || !PagesActive() || PageRefreshInProgress() || !slider.exact_editor || !OwnsValueContext(context) + || SliderFor(context) != &slider) + return; + Root owner(widget), bound(context); + if (!slider.exact_editor->Read(0).actionable() || Invoke(SliderMeta().getContext, owner.get()) != bound.get()) + return; + struct Invocation { + Il2CppObject* previous; + explicit Invocation(Il2CppObject* widget) + : previous(invokingWidget) + { invokingWidget = widget; } + ~Invocation() + { invokingWidget = previous; } + } invocation(owner.get()); + slider.exact_editor->invoke(0); +} void RefreshActions() { if (!OnUIThread() || !actionsActive || !PagesActive() || PageRefreshInProgress()) diff --git a/mods/src/settings/native/action_widgets.h b/mods/src/settings/native/action_widgets.h index 7112d9cf7..e88402e86 100644 --- a/mods/src/settings/native/action_widgets.h +++ b/mods/src/settings/native/action_widgets.h @@ -10,6 +10,8 @@ using ActionRow = std::pair; bool ActionsActive(); // Valid only during a synchronous action invocation; never retain this pointer. Il2CppObject* InvokingActionWidget(); +// Reuses the normal editor owner scope after checking this slider still owns it. +void InvokeSliderEditor(Il2CppObject* widget, Il2CppObject* context, SliderSetting& slider); void RefreshActions(); void RefreshActionPresentations(); ActionRow ActionFor(Il2CppObject* context); diff --git a/mods/src/settings/native/page_navigation.cc b/mods/src/settings/native/page_navigation.cc index cf6815825..226fb6ffb 100644 --- a/mods/src/settings/native/page_navigation.cc +++ b/mods/src/settings/native/page_navigation.cc @@ -111,7 +111,7 @@ void ShowSections(Il2CppObject* controller, Il2CppObject* context, const PageCat if (OwnsValueContext(row)) { if (const auto choice = ChoiceFor(row); choice.first) { id = choice.first->state().id(); - index = choice.second; + index = choice.second + 1; // The group title precedes option zero. } else if (auto* slider = SliderFor(row)) { id = slider->state().id(); } else if (auto* setting = SettingFor(row)) { @@ -122,9 +122,25 @@ void ShowSections(Il2CppObject* controller, Il2CppObject* context, const PageCat if (auto action = ActionFor(row); action.first) { if (!action.first->Read(action.second).visible) continue; + bool inlineEditor = false; + for (auto* slider : page.Controls()) + if (slider->exact_editor == action.first) + for (int j = 0, count = Count(children.get()); j < count; ++j) { + auto* candidate = Item(children.get(), j); + if (SliderFor(candidate) == slider && InlineSliderEditorActive(candidate)) + inlineEditor = true; + } + if (inlineEditor) + continue; id = action.first->id(); index = action.second; } + if (id.empty() && row->klass == HeadingMeta().row.get_cls()) + for (auto* choice : page.Controls()) + if (HasLabel(row, choice->title_id().c_str())) { + id = choice->state().id(); + break; + } if (id.empty()) for (const auto& item : page.items) if (const auto* heading = std::get_if(&item); @@ -395,17 +411,21 @@ NativeCallback headingGetter; bool headingsActive = false; Il2CppString* EmptyHeadingValue(Il2CppObject*, const MethodInfo*) { return il2cpp_string_new(""); } -const PageCatalog::Heading* HeadingFor(Il2CppObject* context) +const std::string* HeadingLabelFor(Il2CppObject* context) { if (!context || context->klass != HeadingMeta().row.get_cls()) return nullptr; auto* callback = reinterpret_cast(ReadField(context, HeadingMeta().queryField)); if (!callback || callback->method != QueryMethod() || callback->method_ptr != QueryMethod()->methodPointer) return nullptr; - for (const auto& page : Pages()) + for (const auto& page : Pages()) { for (const auto& item : page.items) if (auto* heading = std::get_if(&item); heading && HasLabel(context, heading->id.c_str())) - return heading; + return &heading->label; + for (auto* choice : page.Controls()) + if (HasLabel(context, choice->title_id().c_str())) + return &choice->state().label(); + } return nullptr; } void HeadingRefreshHook(auto original, Il2CppObject* widget) @@ -417,11 +437,11 @@ void HeadingRefreshHook(auto original, Il2CppObject* widget) return; try { Root context(Invoke(HeadingMeta().getContext, widget)); - if (const auto* heading = HeadingFor(context.get())) { + if (const auto* heading = HeadingLabelFor(context.get())) { Root label(ReadField(widget, HeadingMeta().label)); // Rich text stays inside the existing local override and is cleared with // it. A darker bar and larger, bold label distinguish a heading from input. - SetRowText(widget, label.get(), "" + heading->label + "", true); + SetRowText(widget, label.get(), "" + *heading + "", true); } } catch (...) { Warn("settings heading unavailable"); @@ -535,7 +555,7 @@ void AddHeadingRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* if (Count(children.get()) != before + 1) throw std::runtime_error("settings heading insertion"); Root row(Item(children.get(), before)); - if (!HeadingFor(row.get()) || !HasLabel(row.get(), heading.id.c_str())) + if (!HeadingLabelFor(row.get()) || !HasLabel(row.get(), heading.id.c_str())) throw std::runtime_error("settings heading identity"); } @@ -665,7 +685,7 @@ void InstallPages() if (std::any_of(Pages().begin(), Pages().end(), [](const auto& page) { return std::any_of(page.items.begin(), page.items.end(), [](const auto& item) { const auto* heading = std::get_if(&item); - return heading && !heading->collapsible; + return (heading && !heading->collapsible) || std::holds_alternative(item); }); })) { auto& heading = HeadingMeta(); diff --git a/mods/src/settings/native/page_navigation.h b/mods/src/settings/native/page_navigation.h index 494239ad5..ec98f7fba 100644 --- a/mods/src/settings/native/page_navigation.h +++ b/mods/src/settings/native/page_navigation.h @@ -45,6 +45,8 @@ void ClearSectionPage(); void RefreshPageRows(); void RefreshPageSummaries(); void RefreshConditionalSections(); +void AddHeadingRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, + const PageCatalog::Heading& heading); void AddPages(Il2CppObject* director, Il2CppObject* context); void InstallPages(); Il2CppString* EmptyHeadingValue(Il2CppObject*, const MethodInfo*); diff --git a/mods/src/settings/native/value_widget_record.h b/mods/src/settings/native/value_widget_record.h index a4e2fddbf..e09332157 100644 --- a/mods/src/settings/native/value_widget_record.h +++ b/mods/src/settings/native/value_widget_record.h @@ -5,6 +5,7 @@ #include "settings/native_view_state.h" #include "value_widgets.h" #include +#include "prime/Vector2.h" namespace mod_settings::native { @@ -21,6 +22,10 @@ struct ValueWidget { Il2CppGCHandle selectionControl = nullptr; std::array indicators{}; std::array activeBefore{}; + Il2CppGCHandle editButton = nullptr, editObject = nullptr, editToken = nullptr; + std::array editRects{}; + std::array editOffsets{}; + bool editInvoking = false; bool overridden = false; bool hidden = false; bool disabled = false; diff --git a/mods/src/settings/native/value_widgets.cc b/mods/src/settings/native/value_widgets.cc index c38ff8cc1..b358a0ab1 100644 --- a/mods/src/settings/native/value_widgets.cc +++ b/mods/src/settings/native/value_widgets.cc @@ -4,6 +4,7 @@ #include "patches/parts/fc_confirmation_reset.h" #include "settings/forbidden_tech.h" #include "settings/native_boolean_callback.h" +#include "ui_helpers.h" #include "value_widget_record.h" #include #include @@ -23,6 +24,7 @@ namespace NativeCallback setter; NativeCallback query, selectionGetter; NativeCallback selectionSetter; + NativeCallback inlineEditCallback; NativeCallback sliderGetter; NativeCallback sliderSetter; bool selectionActive = false, sliderActive = false; @@ -94,6 +96,208 @@ std::deque& ValueViews() } ValueWidget* renderingView = nullptr; ValueWidget* bindingView = nullptr; +bool ChildOf(Il2CppObject* transform, Il2CppObject* parent); +// Keep only weak component identities across native pooling. An unbound button +// is disabled and has no listeners; rebind can reuse it in the same Unity frame. +std::vector inlineButtons; +void ClearInlineEditor(ValueWidget& view) +{ + using namespace ui; + try { + if (auto* button = Target(view.editButton); Alive(button)) { + Value(button, "set_enabled", false); + Root event(Call(button, "get_onClick")); + Call(event.get(), "RemoveAllListeners"); + } + if (auto* object = Target(view.editObject); Alive(object)) + SetActive(object, false); + if (auto* slider = Target(view.editRects[0]); Alive(slider)) + Value(slider, "set_offsetMax", view.editOffsets[0]); + if (auto* label = Target(view.editRects[1]); Alive(label)) { + Value(label, "set_offsetMin", view.editOffsets[1]); + Value(label, "set_offsetMax", view.editOffsets[2]); + } + } catch (...) { + Warn("inline editor restoration unavailable"); + } + Free(view.editButton); + Free(view.editObject); + for (auto& rect : view.editRects) + Free(rect); + Free(view.editToken); +} +bool InlineSliderEditorActive(Il2CppObject* context) +{ + for (auto& view : ValueViews()) + if (Target(view.context) == context && Target(view.editButton) && Target(view.editToken)) + return true; + return false; +} +void InlineEditClick(Il2CppObject* token, const MethodInfo*) +{ + if (!OnUIThread()) + return; + for (auto& view : ValueViews()) { + if (Target(view.editToken) != token || view.rendering || view.binding || view.requesting || view.clearing + || view.editInvoking) + continue; + try { + Root widget(Target(view.widget)), context(Target(view.context)); + if (!widget.get() || Invoke(SliderMeta().getContext, widget.get()) != context.get()) + return; + auto* setting = SliderFor(context.get()); + if (!setting) + return; + struct Guard { + bool& flag; + Guard(bool& value) + : flag(value) + { flag = true; } + ~Guard() + { flag = false; } + } guard(view.editInvoking); + InvokeSliderEditor(widget.get(), context.get(), *setting); + } catch (...) { + Warn("inline editor unavailable"); + } + return; + } +} +vec2 InlineOffset(Il2CppObject* rect, const char* property) +{ + Root value(Call(rect, property)); + auto* vectors = ui::UnityClass("Vector2"); + if (!value.get() || value.get()->klass != vectors) + throw std::runtime_error("inline editor rect offset"); + return *static_cast(il2cpp_object_unbox(value.get())); +} +Il2CppObject* InlineChild(const char* name, Il2CppObject* parent) +{ + using namespace ui; + Root object(il2cpp_object_new(UnityClass("GameObject"))); + Root text(reinterpret_cast(il2cpp_string_new(name))); + const auto* ctor = IL2CppClassHelper(UnityClass("GameObject")).GetMethodInfoSpecial(".ctor", [](auto n, auto p) { + return n == 1 && Type(p[0], IL2CPP_TYPE_STRING); + }); + void* args[]{text.get()}; + Invoke(ctor, object.get(), args); + SetActive(object.get(), false); + WithType(object.get(), "AddComponent", UnityClass("RectTransform")); + Root transform(Call(object.get(), "get_transform")); + bool world = false; + void* parentArgs[]{parent, &world}; + Call(transform.get(), "SetParent", 2, parentArgs); + Value(object.get(), "set_layer", 5); + return object.get(); +} +void BindInlineEditor(ValueWidget& view, SliderSetting& setting) +{ + using namespace ui; + if (!setting.exact_editor) + return; + try { + if (!inlineEditCallback.method()) { + const auto* schema = + IL2CppClassHelper(il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object")).GetMethodInfo(".ctor", 0); + if (!inlineEditCallback.Initialize(schema, InlineEditClick)) + return; + } + Root parent(Call(Target(view.widget), "get_transform")); + Root slider(Call(ReadField(Target(view.widget), SliderMeta().toggleField), "get_transform")); + Root valueLabel(Call(ReadField(Target(view.widget), SliderMeta().stateField), "get_transform")); + if (ChildOf(valueLabel.get(), slider.get())) + throw std::runtime_error("inline editor value label hierarchy"); + Root bounds(Call(slider.get(), "get_rect")); + if (!bounds.get() || bounds.get()->klass != UnityClass("Rect") + || static_cast(il2cpp_object_unbox(bounds.get()))[2] < 230) + return; // Preserve the separate editor when the native layout is too narrow. + view.editOffsets = {InlineOffset(slider.get(), "get_offsetMax"), InlineOffset(valueLabel.get(), "get_offsetMin"), + InlineOffset(valueLabel.get(), "get_offsetMax")}; + view.editRects[0] = il2cpp_gchandle_new_weakref(slider.get(), false); + view.editRects[1] = il2cpp_gchandle_new_weakref(valueLabel.get(), false); + if (!view.editRects[0] || !view.editRects[1]) + throw std::runtime_error("inline editor layout roots"); + Root name(reinterpret_cast(il2cpp_string_new("CommunityMod_InlineEdit"))); + void* find[]{name.get()}; + Root existing(Call(parent.get(), "Find", 1, find)); + Root object(existing.get() ? Call(existing.get(), "get_gameObject") + : InlineChild("CommunityMod_InlineEdit", parent.get())); + view.editObject = il2cpp_gchandle_new_weakref(object.get(), false); + if (!view.editObject) + throw std::runtime_error("inline editor object root"); + auto* buttons = Class("UnityEngine.UI", "UnityEngine.UI", "Button"); + for (auto it = inlineButtons.begin(); it != inlineButtons.end();) { + if (!Target(*it)) { + Free(*it); + it = inlineButtons.erase(it); + } else + ++it; + } + Root button(existing.get() ? WithType(object.get(), "GetComponent", buttons) + : WithType(object.get(), "AddComponent", buttons)); + if (existing.get() && std::none_of(inlineButtons.begin(), inlineButtons.end(), [&](auto handle) { + return Target(handle) == button.get(); + })) + throw std::runtime_error("inline editor ownership"); + view.editButton = il2cpp_gchandle_new_weakref(button.get(), false); + if (!view.editButton) + throw std::runtime_error("inline editor button root"); + if (!existing.get()) { + const auto handle = il2cpp_gchandle_new_weakref(button.get(), false); + if (!handle) throw std::runtime_error("inline editor component root"); + inlineButtons.push_back(handle); + Root image(WithType(object.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "Image"))); + Value(image.get(), "set_color", color{.18f, .34f, .43f, 1}); + Set(button.get(), "set_targetGraphic", image.get()); + Root caption(InlineChild("Label", Call(object.get(), "get_transform"))); + Root captionRect(Call(caption.get(), "get_transform")); + Value(captionRect.get(), "set_anchorMin", vec2{0, 0}); + Value(captionRect.get(), "set_anchorMax", vec2{1, 1}); + Value(captionRect.get(), "set_offsetMin", vec2{0, 0}); + Value(captionRect.get(), "set_offsetMax", vec2{0, 0}); + Root label(WithType(caption.get(), "AddComponent", Class("Unity.TextMeshPro", "TMPro", "TextMeshProUGUI"))); + Root labelObject(Call(Target(view.label), "get_gameObject")); + Root source(WithType(labelObject.get(), "GetComponent", Class("Unity.TextMeshPro", "TMPro", "TMP_Text"))); + Root font(Call(source.get(), "get_font")); + Set(label.get(), "set_font", font.get()); + Value(label.get(), "set_fontSize", 22.f); + Value(label.get(), "set_alignment", 0x202); + Value(label.get(), "set_color", color{.88f, .95f, .97f, 1}); + Value(label.get(), "set_raycastTarget", false); + Text(label.get(), "Edit"); + SetActive(caption.get(), true); + } + Root rect(Call(object.get(), "get_transform")); + Value(rect.get(), "set_anchorMin", vec2{1, .5f}); + Value(rect.get(), "set_anchorMax", vec2{1, .5f}); + Value(rect.get(), "set_pivot", vec2{1, .5f}); + Value(rect.get(), "set_sizeDelta", vec2{104, 44}); + Value(rect.get(), "set_anchoredPosition", vec2{-12, 0}); + Root token(il2cpp_object_new(il2cpp_class_from_name(il2cpp_get_corlib(), "System", "Object"))); + view.editToken = il2cpp_gchandle_new_weakref(token.get(), false); + if (!view.editToken) + throw std::runtime_error("inline editor callback root"); + Root event(Call(button.get(), "get_onClick")); + const auto* add = IL2CppClassHelper(event.get()->klass).GetMethodInfo("AddListener", 1); + if (!Instance(add, 1, IL2CPP_TYPE_VOID) || !Reference(add->parameters[0])) + throw std::runtime_error("inline editor callback"); + Root callback(MakeDelegate(il2cpp_class_from_type(add->parameters[0]), token.get(), inlineEditCallback.method())); + void* args[]{callback.get()}; + Invoke(add, event.get(), args); + auto sliderMax = view.editOffsets[0], valueMin = view.editOffsets[1], valueMax = view.editOffsets[2]; + sliderMax.x -= 124; + valueMin.x -= 124; + valueMax.x -= 124; + Value(slider.get(), "set_offsetMax", sliderMax); + Value(valueLabel.get(), "set_offsetMin", valueMin); + Value(valueLabel.get(), "set_offsetMax", valueMax); + Value(button.get(), "set_enabled", true); + SetActive(object.get(), true); + } catch (...) { + ClearInlineEditor(view); + Warn("inline editor binding unavailable; using separate Edit row"); + } +} void Restore(ValueWidget& view) { RestoreTint(view.tint); @@ -136,6 +340,7 @@ void Clear(ValueWidget& view) // Restoring interactability synchronously calls DoStateTransition. Suppress // presentation reentry until every override is restored and the slot detached. ClearChoiceStyle(view); + ClearInlineEditor(view); Free(view.widget); Free(view.context); Free(view.label); @@ -178,7 +383,7 @@ bool ChildOf(Il2CppObject* transform, Il2CppObject* parent) ValueWidget& Track(Il2CppObject* widget, Il2CppObject* context) { for (auto& view : ValueViews()) { - if (Target(view.widget) || view.rendering || view.binding || view.requesting || view.clearing) + if (Target(view.widget) || view.rendering || view.binding || view.requesting || view.clearing || view.editInvoking) continue; Clear(view); auto& metadata = WidgetMeta(widget); @@ -233,6 +438,8 @@ ValueWidget& Track(Il2CppObject* widget, Il2CppObject* context) view.state.emplace(*setting); } view.label = weak(label.get()); + if (metadata.slider) + BindInlineEditor(view, *SliderFor(context)); if (metadata.selection || metadata.slider) view.selectionControl = weak(selectionControl.get()); if (!metadata.selection) @@ -402,10 +609,12 @@ void AddChoiceRows(Il2CppObject* director, Il2CppObject* context, Il2CppObject* auto& m = SelectionMeta(); const auto* add = m.context.GetMethodInfo("AddSelection", 6); Root children(Call(parent, "get_Children")); - const int before = Count(children.get()); + const int before = Count(children.get()) + 1; const auto count = setting.labels().size(); if (before + count > PageCatalog::NativeChildLimit) throw std::runtime_error("selection category capacity"); + // A display-only title belongs to the choice, not a new collapsible section. + AddHeadingRow(director, context, parent, {setting.title_id(), setting.state().label()}); Root values(reinterpret_cast( il2cpp_array_new(il2cpp_class_from_name(il2cpp_get_corlib(), "System", "String"), count))); for (std::size_t i = 0; i < count; ++i) { @@ -598,7 +807,7 @@ void RefreshViews() if (!OnUIThread()) return; for (auto& view : ValueViews()) { - if (view.requesting || view.rendering || view.binding || view.clearing) + if (view.requesting || view.rendering || view.binding || view.clearing || view.editInvoking) continue; Root widget(Target(view.widget)); if (!widget.get()) @@ -779,7 +988,7 @@ void PrepareValueViews(std::size_t count) bool ValueWidgetsBusy() { for (const auto& view : ValueViews()) - if (view.requesting || view.rendering || view.binding || view.clearing) + if (view.requesting || view.rendering || view.binding || view.clearing || view.editInvoking) return true; return false; } diff --git a/mods/src/settings/native/value_widgets.h b/mods/src/settings/native/value_widgets.h index ab8759914..2bd60645e 100644 --- a/mods/src/settings/native/value_widgets.h +++ b/mods/src/settings/native/value_widgets.h @@ -60,6 +60,7 @@ void AddChoiceRows(Il2CppObject* director, Il2CppObject* context, Il2CppObject* void AddSliderRow(Il2CppObject* director, Il2CppObject* context, Il2CppObject* parent, SliderSetting& setting); void PrepareValueViews(std::size_t count); bool ValueWidgetsBusy(); +bool InlineSliderEditorActive(Il2CppObject* context); void RefreshViews(); void InstallChoiceAndSliderWidgets(); bool InstallCoreValueWidgets(); // True only for this first successful installation. diff --git a/mods/src/settings/slider_setting.h b/mods/src/settings/slider_setting.h index 0793afe5d..d6e9155a1 100644 --- a/mods/src/settings/slider_setting.h +++ b/mods/src/settings/slider_setting.h @@ -5,6 +5,7 @@ #include namespace mod_settings { +struct ActionSetting; enum class SliderLabel { Value, Percentage }; class SliderSetting @@ -25,6 +26,7 @@ class SliderSetting if (!std::isfinite(step) || step <= 0) throw std::invalid_argument("slider step"); } + ActionSetting* exact_editor = nullptr; // Canonical full-range editor, if one exists. ValueSetting& state() { return state_; } float minimum() const diff --git a/mods/src/settings/upstream_settings.cc b/mods/src/settings/upstream_settings.cc index 396384ab1..0ba3ce67f 100644 --- a/mods/src/settings/upstream_settings.cc +++ b/mods/src/settings/upstream_settings.cc @@ -3,6 +3,9 @@ #include "config.h" #include "defaultconfig.h" #include "file.h" +#include "image_file_setting.h" +#include "patches/screen_update_hook.h" +#include "native/action_widgets.h" #include "forbidden_tech.h" #include "mod_pages.h" #include "patches/mission_hud.h" @@ -33,6 +36,15 @@ namespace constexpr auto root = "community_mod.settings"; using Timing = ApplyTiming; using Available = std::function; + std::shared_ptr loadingImagePicker; + void PollLoadingImagePicker() + { + if (loadingImagePicker && loadingImagePicker->Poll()) { +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) + native::RefreshActions(); +#endif + } + } std::vector> adapters; std::map, std::less<>> currentSavers; @@ -175,7 +187,8 @@ namespace // slider's range-limited reader. Existing custom values remain readable and can // be edited without first moving a slider or normalizing the TOML file. template - void ExactEntry(PageCatalog& catalog, const char* page, const std::shared_ptr>& setting, Parse parse) + std::shared_ptr ExactEntry(PageCatalog& catalog, const char* page, + const std::shared_ptr>& setting, Parse parse) { auto action = Keep(); action->identity = setting->id() + ".exact"; @@ -228,6 +241,7 @@ namespace }}); }; catalog.AddAction(page, *action); + return action; } template @@ -264,12 +278,14 @@ namespace if (sliderMaximum > static_cast(minimum)) definition.metadata.description += "\nThe slider is a convenience range; Edit also accepts valid custom values."; auto exact = Keep>(definition); - ExactEntry(catalog, page, exact, - [minimum, maximum](std::string_view text) { return ParseNumber(text, minimum, maximum); }); + auto editor = ExactEntry(catalog, page, exact, [minimum, maximum](std::string_view text) { + return ParseNumber(text, minimum, maximum); + }); if constexpr (std::is_same_v) { if (sliderMaximum > static_cast(minimum)) { auto slider = Keep(std::move(definition), static_cast(minimum), sliderMaximum, step, std::move(available), SliderLabel::Value, 2); + slider->exact_editor = editor.get(); catalog.AddSlider(page, *slider); } } @@ -799,15 +815,18 @@ void RegisterUpstreamSettings(PageCatalog& catalog) [loading, transition] { return loading() || transition(); }, Timing::Restart, "Path to an image readable by the game. Empty uses the embedded fallback. This does not move or copy the " "image.")); - ExactEntry(catalog, "community_mod.loading", image, [](std::string_view value) -> std::optional { - value = Trim(value); - if (value.empty()) - return std::string{}; - std::error_code error; - return std::filesystem::is_regular_file(std::filesystem::u8path(value), error) && !error - ? std::optional{std::string(value)} - : std::nullopt; - }); + auto imageAction = + ExactEntry(catalog, "community_mod.loading", image, [](std::string_view value) -> std::optional { + value = Trim(value); + if (value.empty()) + return std::string{}; + std::error_code error; + return std::filesystem::is_regular_file(std::filesystem::u8path(value), error) && !error + ? std::optional{std::string(value)} + : std::nullopt; + }); + if (install_screen_manager_update_hook() && register_screen_manager_update_callback(PollLoadingImagePicker)) + loadingImagePicker = Keep(image, *imageAction); Lists(catalog); Information(catalog, "community_mod.advanced", "community_mod.configuration.path", "Configuration file", [] { const auto path = File::MakePath(File::Config()); diff --git a/tests/image_file_setting_test.cc b/tests/image_file_setting_test.cc new file mode 100644 index 000000000..046198f31 --- /dev/null +++ b/tests/image_file_setting_test.cc @@ -0,0 +1,76 @@ +#include "settings/image_file_setting.h" +#include +#include +#include +using namespace mod_settings; +std::promise picked; +std::future OpenImageFilePicker() +{ + picked = std::promise{}; + return picked.get_future(); +} +void Check(bool condition) +{ + if (!condition) + throw std::runtime_error("image picker fixture failed"); +} +int main(int argc, char** argv) +{ + Check(argc == 2); + const auto directory = std::filesystem::path(argv[1]); + std::filesystem::create_directories(directory); + const auto image = directory / "loading.png"; + std::ofstream(image) << "existing path boundary fixture"; + std::string requested, active; + int writes = 0; + ValueDefinition definition{"image", "Loading image path", + [&] { return ValueReadResult::Known(requested, 1); }, + [&](std::string value, std::uint64_t) { + requested = std::move(value); + return ApplyResult::Applied; + }}; + definition.default_value = ""; + definition.persist = [&](const auto&) { ++writes; }; + definition.active_value = [&] { return std::optional(active); }; + auto setting = std::make_shared>(std::move(definition)); + ActionSetting action; + action.read = [&](auto) { return ActionSetting::Presentation{"Loading image path", "Edit", requested}; }; + action.reset = [&] { setting->Reset(setting->Observe()); }; + action.prepare_reset = [&] { return PrepareReset(*setting); }; + ImageFileSetting picker(setting, action); + action.invoke(0); + Check(!action.Read(0).actionable()); + Check(picker.Poll()); // Initial choosing status is presented without waiting. + picked.set_value(image.string()); + Check(picker.Poll() && requested == image.string() && active.empty() && writes == 1); + Check(action.Read(0).actionable()); + action.invoke(0); + picked.set_value(""); + picker.Poll(); + Check(requested == image.string() && writes == 1); // Cancel never clears a preference. + action.invoke(0); + setting->SetFromUser("other.png", setting->Observe()); + const auto afterEdit = writes; + picked.set_value(image.string()); + picker.Poll(); + Check(requested == "other.png" && writes == afterEdit); // Stale picker cannot overwrite a later edit. + action.reset(); + action.invoke(0); + auto reset = action.prepare_reset(); + Check(reset.current() && reset.apply()); // Even an unchanged-default reset cancels pending intent. + const auto afterReset = writes; + picked.set_value(image.string()); + picker.Poll(); + Check(requested.empty() && writes == afterReset); + action.invoke(0); + picked.set_value((directory / "missing.png").string()); + picker.Poll(); + Check(requested.empty() && writes == afterReset); + action.invoke(0); + picked.set_exception(std::make_exception_ptr(std::runtime_error("dialog failure"))); + picker.Poll(); + Check(action.Read(0).actionable() && writes == afterReset); + std::filesystem::remove(image); + std::cout + << "Image picker: async admission, cancel, stale edit/reset, missing file, failure and restart timing passed\n"; +} diff --git a/tests/upstream_settings_test.cc b/tests/upstream_settings_test.cc index 7c7b27be2..629604a92 100644 --- a/tests/upstream_settings_test.cc +++ b/tests/upstream_settings_test.cc @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -494,6 +495,19 @@ std::u8string File::MakePath(std::string_view, bool, bool) { return u8"fixture://configuration/fixture-settings.toml"; } #endif +// This catalog fixture deliberately exercises the manual-editor fallback. +// Asynchronous image selection is covered by image_file_setting_test.cc. +bool install_screen_manager_update_hook() +{ return false; } +bool register_screen_manager_update_callback(void (*)()) +{ return false; } +std::future OpenImageFilePicker() +{ return {}; } +namespace mod_settings::native +{ +void RefreshActions() {} +} // namespace mod_settings::native + namespace runtime_config { bool RegisterSetting(const char* section, const char* key) noexcept From 10f7b82634e03ac173cc6e47993e4ef8837f7c2f Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 23:10:56 -0500 Subject: [PATCH 11/17] Keep inline editors usable with nested values and failed construction --- .../src/settings/native/value_widget_record.h | 2 +- mods/src/settings/native/value_widgets.cc | 76 +++++++++++++------ 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/mods/src/settings/native/value_widget_record.h b/mods/src/settings/native/value_widget_record.h index e09332157..ff3f3a4b1 100644 --- a/mods/src/settings/native/value_widget_record.h +++ b/mods/src/settings/native/value_widget_record.h @@ -24,7 +24,7 @@ struct ValueWidget { std::array activeBefore{}; Il2CppGCHandle editButton = nullptr, editObject = nullptr, editToken = nullptr; std::array editRects{}; - std::array editOffsets{}; + std::array editOffsets{}; bool editInvoking = false; bool overridden = false; bool hidden = false; diff --git a/mods/src/settings/native/value_widgets.cc b/mods/src/settings/native/value_widgets.cc index b358a0ab1..8f0967da1 100644 --- a/mods/src/settings/native/value_widgets.cc +++ b/mods/src/settings/native/value_widgets.cc @@ -111,11 +111,13 @@ void ClearInlineEditor(ValueWidget& view) } if (auto* object = Target(view.editObject); Alive(object)) SetActive(object, false); - if (auto* slider = Target(view.editRects[0]); Alive(slider)) - Value(slider, "set_offsetMax", view.editOffsets[0]); + if (auto* slider = Target(view.editRects[0]); Alive(slider)) { + Value(slider, "set_offsetMin", view.editOffsets[0]); + Value(slider, "set_offsetMax", view.editOffsets[1]); + } if (auto* label = Target(view.editRects[1]); Alive(label)) { - Value(label, "set_offsetMin", view.editOffsets[1]); - Value(label, "set_offsetMax", view.editOffsets[2]); + Value(label, "set_offsetMin", view.editOffsets[2]); + Value(label, "set_offsetMax", view.editOffsets[3]); } } catch (...) { Warn("inline editor restoration unavailable"); @@ -193,6 +195,7 @@ Il2CppObject* InlineChild(const char* name, Il2CppObject* parent) void BindInlineEditor(ValueWidget& view, SliderSetting& setting) { using namespace ui; + bool provisional = false; if (!setting.exact_editor) return; try { @@ -202,26 +205,25 @@ void BindInlineEditor(ValueWidget& view, SliderSetting& setting) if (!inlineEditCallback.Initialize(schema, InlineEditClick)) return; } - Root parent(Call(Target(view.widget), "get_transform")); - Root slider(Call(ReadField(Target(view.widget), SliderMeta().toggleField), "get_transform")); - Root valueLabel(Call(ReadField(Target(view.widget), SliderMeta().stateField), "get_transform")); - if (ChildOf(valueLabel.get(), slider.get())) - throw std::runtime_error("inline editor value label hierarchy"); - Root bounds(Call(slider.get(), "get_rect")); - if (!bounds.get() || bounds.get()->klass != UnityClass("Rect") - || static_cast(il2cpp_object_unbox(bounds.get()))[2] < 230) - return; // Preserve the separate editor when the native layout is too narrow. - view.editOffsets = {InlineOffset(slider.get(), "get_offsetMax"), InlineOffset(valueLabel.get(), "get_offsetMin"), + Root parent(Call(Target(view.widget), "get_transform")); + Root slider(Call(ReadField(Target(view.widget), SliderMeta().toggleField), "get_transform")); + Root valueLabel(Call(ReadField(Target(view.widget), SliderMeta().stateField), "get_transform")); + const bool nestedValue = ChildOf(valueLabel.get(), slider.get()); + // Move the existing control block intact; its value label may be nested. + view.editOffsets = {InlineOffset(slider.get(), "get_offsetMin"), InlineOffset(slider.get(), "get_offsetMax"), + InlineOffset(valueLabel.get(), "get_offsetMin"), InlineOffset(valueLabel.get(), "get_offsetMax")}; view.editRects[0] = il2cpp_gchandle_new_weakref(slider.get(), false); - view.editRects[1] = il2cpp_gchandle_new_weakref(valueLabel.get(), false); - if (!view.editRects[0] || !view.editRects[1]) + if (!nestedValue) + view.editRects[1] = il2cpp_gchandle_new_weakref(valueLabel.get(), false); + if (!view.editRects[0] || (!nestedValue && !view.editRects[1])) throw std::runtime_error("inline editor layout roots"); Root name(reinterpret_cast(il2cpp_string_new("CommunityMod_InlineEdit"))); void* find[]{name.get()}; Root existing(Call(parent.get(), "Find", 1, find)); Root object(existing.get() ? Call(existing.get(), "get_gameObject") : InlineChild("CommunityMod_InlineEdit", parent.get())); + provisional = !existing.get(); view.editObject = il2cpp_gchandle_new_weakref(object.get(), false); if (!view.editObject) throw std::runtime_error("inline editor object root"); @@ -243,9 +245,6 @@ void BindInlineEditor(ValueWidget& view, SliderSetting& setting) if (!view.editButton) throw std::runtime_error("inline editor button root"); if (!existing.get()) { - const auto handle = il2cpp_gchandle_new_weakref(button.get(), false); - if (!handle) throw std::runtime_error("inline editor component root"); - inlineButtons.push_back(handle); Root image(WithType(object.get(), "AddComponent", Class("UnityEngine.UI", "UnityEngine.UI", "Image"))); Value(image.get(), "set_color", color{.18f, .34f, .43f, 1}); Set(button.get(), "set_targetGraphic", image.get()); @@ -259,6 +258,8 @@ void BindInlineEditor(ValueWidget& view, SliderSetting& setting) Root labelObject(Call(Target(view.label), "get_gameObject")); Root source(WithType(labelObject.get(), "GetComponent", Class("Unity.TextMeshPro", "TMPro", "TMP_Text"))); Root font(Call(source.get(), "get_font")); + if (!font.get()) + throw std::runtime_error("inline editor font unavailable"); Set(label.get(), "set_font", font.get()); Value(label.get(), "set_fontSize", 22.f); Value(label.get(), "set_alignment", 0x202); @@ -266,6 +267,11 @@ void BindInlineEditor(ValueWidget& view, SliderSetting& setting) Value(label.get(), "set_raycastTarget", false); Text(label.get(), "Edit"); SetActive(caption.get(), true); + const auto handle = il2cpp_gchandle_new_weakref(button.get(), false); + if (!handle) + throw std::runtime_error("inline editor component root"); + inlineButtons.push_back(handle); + provisional = false; } Root rect(Call(object.get(), "get_transform")); Value(rect.get(), "set_anchorMin", vec2{1, .5f}); @@ -284,21 +290,41 @@ void BindInlineEditor(ValueWidget& view, SliderSetting& setting) Root callback(MakeDelegate(il2cpp_class_from_type(add->parameters[0]), token.get(), inlineEditCallback.method())); void* args[]{callback.get()}; Invoke(add, event.get(), args); - auto sliderMax = view.editOffsets[0], valueMin = view.editOffsets[1], valueMax = view.editOffsets[2]; + auto sliderMin = view.editOffsets[0], sliderMax = view.editOffsets[1]; + sliderMin.x -= 124; sliderMax.x -= 124; - valueMin.x -= 124; - valueMax.x -= 124; + Value(slider.get(), "set_offsetMin", sliderMin); Value(slider.get(), "set_offsetMax", sliderMax); - Value(valueLabel.get(), "set_offsetMin", valueMin); - Value(valueLabel.get(), "set_offsetMax", valueMax); + if (!nestedValue) { + auto valueMin = view.editOffsets[2], valueMax = view.editOffsets[3]; + valueMin.x -= 124; + valueMax.x -= 124; + Value(valueLabel.get(), "set_offsetMin", valueMin); + Value(valueLabel.get(), "set_offsetMax", valueMax); + } Value(button.get(), "set_enabled", true); SetActive(object.get(), true); } catch (...) { + // Publish only complete controls; a same-frame rebind must not find a partial child. + Root incomplete(provisional ? Target(view.editObject) : nullptr); ClearInlineEditor(view); + if (incomplete.get()) { + try { + Root transform(Call(incomplete.get(), "get_transform")); + bool world = false; + void* parentArgs[]{nullptr, &world}; + Call(transform.get(), "SetParent", 2, parentArgs); + static const auto* destroy = IL2CppClassHelper(UnityClass("Object")).GetMethodInfo("Destroy", 1); + void* args[]{incomplete.get()}; + Static(destroy, args); + } catch (...) { + Warn("incomplete inline editor disposal unavailable"); + } + } Warn("inline editor binding unavailable; using separate Edit row"); } } -void Restore(ValueWidget& view) +void Restore(ValueWidget& view) { RestoreTint(view.tint); if (view.disabled) { From 44cd06218d9a59b3fccd9abecdd42ba0733759b4 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 23:18:38 -0500 Subject: [PATCH 12/17] Align numeric editor display with slider decimals --- mods/src/settings/upstream_settings.cc | 25 +++++++++++++++---- tests/upstream_settings_test.cc | 34 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/mods/src/settings/upstream_settings.cc b/mods/src/settings/upstream_settings.cc index 0ba3ce67f..ba485280e 100644 --- a/mods/src/settings/upstream_settings.cc +++ b/mods/src/settings/upstream_settings.cc @@ -15,6 +15,7 @@ #include "text_entry.h" #include "warp_mode.h" +#include #include #include #include @@ -68,8 +69,18 @@ namespace if constexpr (std::is_same_v) return value; else { - char text[128]; - const auto result = std::to_chars(text, text + sizeof(text), value); + char text[128]; + auto displayed = value; + if constexpr (std::is_floating_point_v) { + // Match the slider's two-decimal presentation only for floating-point + // residue. Keep meaningful custom precision, including tiny values. + const auto rounded = static_cast(std::round(static_cast(value) * 100.0) / 100.0); + if (std::isfinite(rounded) && rounded != 0 + && std::abs(rounded - value) + <= 4 * std::numeric_limits::epsilon() * std::max(std::abs(value), std::abs(rounded))) + displayed = rounded; + } + const auto result = std::to_chars(text, text + sizeof(text), displayed); return result.ec == std::errc{} ? std::string(text, result.ptr) : std::string{}; } } @@ -224,19 +235,23 @@ namespace const auto text = Format(*value); help += "\nDefault: " + (text.empty() ? std::string("(empty)") : text); } + const auto initial = Format(*observed.state.value); OpenTextEntry({.title = setting->label(), - .value = Format(*observed.state.value), + .value = initial, .help = std::move(help), .validate = [parse](std::string_view text) { return parse(text) ? std::string{} : std::string("Enter a valid value."); }, .commit = - [setting, observed, parse](std::string_view text) { + [setting, observed, parse, initial](std::string_view text) { const auto value = parse(text); if (!value) return false; - const auto result = setting->SetFromUser(*value, observed).outcome; + // Accepting unchanged presentation must not round the + // canonical value, and must still reject stale edits. + const auto result = + setting->SetFromUser(text == initial ? *observed.state.value : *value, observed).outcome; return result == Outcome::AppliedVerified || result == Outcome::Unchanged; }}); }; diff --git a/tests/upstream_settings_test.cc b/tests/upstream_settings_test.cc index 629604a92..ffb73222e 100644 --- a/tests/upstream_settings_test.cc +++ b/tests/upstream_settings_test.cc @@ -830,6 +830,40 @@ int main(int argc, char** argv) std::locale::global(previousLocale); Require(ReadAll(reads) == defaults, "numeric locale test did not restore the baseline"); + // Slider arithmetic can leave a float just below a displayed decimal. + // Edit should show the same decimal without rewriting it on unchanged Apply. + auto* scaleStep = models.at("graphics.ui_scale_step").exact; + const auto stepBaseline = Config::Get().ui_scale_step; + for (const auto [stored, shown] : {std::pair{.19999999f, "0.2"}, std::pair{1.999999f, "2"}}) { + Config::Get().ui_scale_step = stored; + writes.clear(); + scaleStep->invoke(0); + Require(modal && modal->value == shown, "Edit exposed slider floating-point residue"); + Require(modal->validate(modal->value).empty() && modal->commit(modal->value), "unchanged Apply failed"); + Require(Config::Get().ui_scale_step == stored && writes.size() == 1 + && writes.back().key == "graphics.ui_scale_step" + && std::get(writes.back().value) == static_cast(stored), + "unchanged Apply rounded the native or saved float"); + writes.clear(); + Config::Get().ui_scale_step = .3f; + Require(!modal->commit(modal->value) && writes.empty(), "unchanged presentation bypassed stale guard"); + modal.reset(); + } + for (const auto stored : {1.234567f, .00000123f}) { + Config::Get().ui_scale_step = stored; + scaleStep->invoke(0); + Require(modal && std::stof(modal->value) == stored, "custom precision was lost in Edit"); + Require(modal->commit(modal->value) && Config::Get().ui_scale_step == stored, + "unchanged custom precision was lost on Apply"); + modal.reset(); + } + Config::Get().ui_scale_step = .19999999f; + scaleStep->invoke(0); + Require(modal && modal->commit("0.25") && Config::Get().ui_scale_step == .25f, + "changed precise entry did not apply"); + modal.reset(); + Config::Get().ui_scale_step = stepBaseline; + // A stale exact editor and a stale prepared reset cannot overwrite a later // native shortcut mutation. No worker/config-file behavior is simulated. auto* speed = models.at("graphics.keyboard_zoom_speed").exact; From 6bcbff01d81a400c93e31b2f94b45ecad37c2675 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 23:29:38 -0500 Subject: [PATCH 13/17] Make daily bulk-claim factions independently selectable --- docs/MOD_SETTINGS.md | 6 +- docs/settings-coverage.json | 2 +- example_community_patch_settings_en.toml | 2 +- mods/src/config.cc | 3 +- mods/src/config_filters.cc | 12 +++- mods/src/settings/choice_setting.h | 43 +++++++++--- mods/src/settings/maintenance.h | 2 +- .../src/settings/native/value_widget_record.h | 4 +- mods/src/settings/native/value_widgets.cc | 32 +++++++++ mods/src/settings/native_view_state.h | 21 +++++- mods/src/settings/upstream_settings.cc | 55 +++++++++++----- tests/choice_setting_test.cc | 30 +++++++++ tests/upstream_settings_test.cc | 66 +++++++++++++++++-- 13 files changed, 236 insertions(+), 42 deletions(-) diff --git a/docs/MOD_SETTINGS.md b/docs/MOD_SETTINGS.md index bd602c9bd..3ac398a02 100644 --- a/docs/MOD_SETTINGS.md +++ b/docs/MOD_SETTINGS.md @@ -20,7 +20,9 @@ Open a page's **Defaults & Help** to read setting details, restore one default, Single-choice checkbox groups show their setting title above the options. Use **Edit** to the right of a slider for an exact numeric value; numeric controls without a slider retain an Edit button. Convenience sliders have useful ranges; an existing custom value outside that range remains visible in the exact editor and is preserved until explicitly changed. Numeric entries reject invalid syntax, non-finite numbers, and values outside a setting's valid domain. Integer controls require whole numbers. Units and setting-specific limits appear with the control. -Ship filter lists accept comma-separated names using the same matching rules as TOML. Instant warp filters accept `*`; pinned ships require names. Faction and banner lists validate names against the startup parser rather than silently discarding unknown entries. Clear a list explicitly to remove its entries. +Ship filter lists accept comma-separated names using the same matching rules as TOML. Instant warp filters accept `*`; pinned ships require names. Banner lists validate names against the startup parser rather than silently discarding unknown entries. Clear a list explicitly to remove its entries. + +**Daily bulk-claim factions** provides independent Federation, Klingon and Romulan checkboxes. Select any combination; uncheck all to skip those faction dailies while keeping other dailies eligible. All three checked is the default. TOML retains its comma-separated string: blank means all factions and `none` means none. Reopen Daily Goals after changing the selection. Use **Choose file** on the loading image control to select a PNG or JPEG. Cancel leaves the current path unchanged. Reset its default in **Defaults & Help** to use the embedded fallback. Selecting a path does not copy, move, or modify the image file, and the loading asset changes require restart. @@ -70,7 +72,7 @@ Use reversible preferences for these player checks; no claim or purchase is requ 3. Enter an exact number, an invalid number, and a valid value outside its convenience slider range. Cancel an edit and verify no change or save occurred. Check interface scale at the smallest supported window size. 4. Change a restart preference and verify it shows pending restart while the active behavior stays unchanged. Restore it afterward. 5. Review a single default and a page reset. Cancel each once, then confirm with reversible preferences. Check a conditional slider and its exact editor reset only once. -6. Edit a ship list, faction list, banner list, and optional loading-image path. Check empty values, invalid entries, and a path containing spaces or non-ASCII characters. +6. Edit a ship list, banner list, and optional loading-image path. Check empty values, invalid entries, and a path containing spaces or non-ASCII characters. For Daily bulk-claim factions, verify independent combinations, all unchecked, save/reopen, Defaults restoring all three, and that bulk claim skips unchecked main factions but keeps other dailies eligible. 7. Record, cancel, replace and remove a shortcut; restore its default and undo. Include an initially `NONE` action and a modifier combination. Verify no gameplay hotkey fires while typing, recording, or releasing held keys. Use Command combinations on Mac. 8. Switch HUD buttons through Auto, Always and Never and restore Auto. Switch fleet labels through Native and overrides, change thresholds, then change system/scene and reopen Settings. 9. With a copy of the config, exercise a read-only file and an external edit to the same key. Check the failure stays visible after saving another key. Resolve the conflict, retry that key, and verify unrelated comments and values survive. Quit immediately after an edit and check it survives the next launch. diff --git a/docs/settings-coverage.json b/docs/settings-coverage.json index a2e643d18..b3da93a6a 100644 --- a/docs/settings-coverage.json +++ b/docs/settings-coverage.json @@ -121,7 +121,7 @@ {"key": "ui.auto_confirm_reveal_ability", "control": "boolean", "page": "claims", "application": "live", "treatment": "in_game"}, {"key": "ui.auto_open_bulk_claim_flyout", "control": "boolean", "page": "claims", "application": "live", "treatment": "in_game"}, {"key": "ui.cargo_significant_decimals", "control": "number", "page": "previews", "application": "reopen_view", "treatment": "in_game"}, - {"key": "ui.daily_bulk_claim_factions", "control": "list", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, + {"key": "ui.daily_bulk_claim_factions", "control": "multi_select", "page": "claims", "application": "reopen_view", "treatment": "in_game"}, {"key": "ui.daily_bulk_claim_toggle_default_on", "control": "boolean", "page": "claims", "application": "restart", "treatment": "in_game"}, {"key": "ui.disable_escape_exit", "control": "boolean", "page": "interface", "application": "live", "treatment": "in_game"}, {"key": "ui.disable_escape_exit_timer", "control": "number", "page": "interface", "application": "live", "treatment": "in_game"}, diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 4d9cc539f..e0222efa5 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -794,7 +794,7 @@ extend_chest_purchase_max = 160 # Subgroup: Daily Goals # --------------------- -# Comma-separated factions (federation, klingon, romulan) for which the Daily Goals bulk-claim toggle applies; leave blank to preserve the game's default behaviour +# Comma-separated factions (federation, klingon, romulan) for Daily Goals bulk claim. Blank includes all factions; "none" skips all three. Non-faction dailies remain eligible. daily_bulk_claim_factions = "" # Force the Daily Goals Claim All toggle back on the first time the screen is shown each session diff --git a/mods/src/config.cc b/mods/src/config.cc index b2b84306e..e5c3ca1fc 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -544,7 +544,8 @@ void read_daily_bulk_claim_factions(toml::table& config, toml::table& new_config const auto value = config["ui"]["daily_bulk_claim_factions"].value().value_or(std::string(default_value)); if (!ParseFactionFilter(value, factions)) - spdlog::warn("Unrecognised faction in daily_bulk_claim_factions; expected federation, klingon or romulan. Ignoring " + spdlog::warn("Unrecognised faction in daily_bulk_claim_factions; expected federation, klingon, romulan, or none by " + "itself. Ignoring " "unknown names."); new_config.emplace("ui", toml::table()); diff --git a/mods/src/config_filters.cc b/mods/src/config_filters.cc index ce65d4437..9aec5a50f 100644 --- a/mods/src/config_filters.cc +++ b/mods/src/config_filters.cc @@ -75,7 +75,7 @@ bool ParseFactionFilter(std::string_view value, std::vector& factio static constexpr std::array kKnownFactions{"federation", "klingon", "romulan"}; factions.clear(); - bool valid = true; + bool valid = true, none = false; for (const auto& token : StrSplit(std::string(value), ',')) { const auto stripped = StripAsciiWhitespace(token); if (stripped.empty()) @@ -83,6 +83,10 @@ bool ParseFactionFilter(std::string_view value, std::vector& factio auto lowered = AsciiStrToLower(stripped); + if (lowered == "none") { + none = true; + continue; + } if (std::ranges::find(kKnownFactions, lowered) == kKnownFactions.end()) { valid = false; continue; @@ -92,6 +96,12 @@ bool ParseFactionFilter(std::string_view value, std::vector& factio factions.emplace_back(std::move(lowered)); } } + if (none) { + // Keep a nonempty sentinel: an empty filter preserves native claiming. + // A contradictory manual list fails closed for the three main factions. + valid = valid && factions.empty(); + factions = {"none"}; + } return valid; } diff --git a/mods/src/settings/choice_setting.h b/mods/src/settings/choice_setting.h index a7bb04265..871cbcde5 100644 --- a/mods/src/settings/choice_setting.h +++ b/mods/src/settings/choice_setting.h @@ -4,12 +4,15 @@ namespace mod_settings { +enum class ChoiceMode { Single, Multiple }; + class ChoiceSetting { public: - ChoiceSetting(ValueDefinition definition, std::vector labels) + ChoiceSetting(ValueDefinition definition, std::vector labels, ChoiceMode mode = ChoiceMode::Single) : labels_(std::move(labels)) - , state_(Checked(std::move(definition), labels_.size())) + , mode_(mode) + , state_(Checked(std::move(definition), labels_.size(), mode)) { for (const auto& label : labels_) if (label.empty()) @@ -19,33 +22,55 @@ class ChoiceSetting { return state_; } const std::vector& labels() const { return labels_; } + bool multiple() const + { return mode_ == ChoiceMode::Multiple; } + bool Contains(int selected, int index) const + { + return index >= 0 && index < static_cast(labels_.size()) + && (multiple() ? (selected & (1 << index)) != 0 : selected == index); + } + std::string SelectionLabel(int selected) const + { + if (!multiple()) + return labels_.at(selected); + std::string text; + for (int i = 0; i < static_cast(labels_.size()); ++i) + if (Contains(selected, i)) { + if (!text.empty()) + text += ", "; + text += labels_[i]; + } + return text.empty() ? "None" : text; + } std::string title_id() const { return state_.id() + ".title"; } std::string item_id(int index) const { return state_.id() + "." + std::to_string(index); } private: - static ValueDefinition Checked(ValueDefinition definition, std::size_t count) + static ValueDefinition Checked(ValueDefinition definition, std::size_t count, ChoiceMode mode) { if (count < 2 || count > 16 || definition.id.empty() || definition.label.empty() || !definition.read || !definition.write) throw std::invalid_argument("choice setting definition"); - auto read = std::move(definition.read); - auto write = std::move(definition.write); - definition.read = [read = std::move(read), count] { + const auto limit = mode == ChoiceMode::Multiple ? (1 << count) : static_cast(count); + auto read = std::move(definition.read); + auto write = std::move(definition.write); + definition.read = [read = std::move(read), limit] { auto result = read(); - if (result.known() && (*result.value < 0 || static_cast(*result.value) >= count)) + if (result.known() && (*result.value < 0 || *result.value >= limit)) return ValueReadResult{}; return result; }; - definition.write = [write = std::move(write), count](int value, std::uint64_t generation) { - if (value < 0 || static_cast(value) >= count) + definition.write = [write = std::move(write), limit](int value, std::uint64_t generation) { + if (value < 0 || value >= limit) return ApplyResult::Rejected; return write(value, generation); }; return definition; } std::vector labels_; + ChoiceMode mode_; ValueSetting state_; }; } // namespace mod_settings diff --git a/mods/src/settings/maintenance.h b/mods/src/settings/maintenance.h index cbfb67566..8edf28a6f 100644 --- a/mods/src/settings/maintenance.h +++ b/mods/src/settings/maintenance.h @@ -56,7 +56,7 @@ inline void RegisterMaintenancePages(PageCatalog& catalog) if constexpr (std::is_same_v) return std::string(defaultValue ? "On" : "Off"); else if constexpr (std::is_same_v) - return value->labels().at(defaultValue); + return value->SelectionLabel(defaultValue); else return std::to_string(defaultValue); }, diff --git a/mods/src/settings/native/value_widget_record.h b/mods/src/settings/native/value_widget_record.h index ff3f3a4b1..9706abe46 100644 --- a/mods/src/settings/native/value_widget_record.h +++ b/mods/src/settings/native/value_widget_record.h @@ -19,7 +19,9 @@ struct ValueWidget { Il2CppGCHandle choiceBackground = nullptr, choiceOverrideBefore = nullptr, choiceCheck = nullptr; bool choiceStyled = false, pressed = false; Il2CppGCHandle widget = nullptr, context = nullptr, label = nullptr; - Il2CppGCHandle selectionControl = nullptr; + Il2CppGCHandle selectionControl = nullptr; + Il2CppGCHandle selectionGroup = nullptr; + bool selectionGroupDetached = false; std::array indicators{}; std::array activeBefore{}; Il2CppGCHandle editButton = nullptr, editObject = nullptr, editToken = nullptr; diff --git a/mods/src/settings/native/value_widgets.cc b/mods/src/settings/native/value_widgets.cc index 8f0967da1..0ff352eb4 100644 --- a/mods/src/settings/native/value_widgets.cc +++ b/mods/src/settings/native/value_widgets.cc @@ -366,6 +366,18 @@ void Clear(ValueWidget& view) // Restoring interactability synchronously calls DoStateTransition. Suppress // presentation reentry until every override is restored and the slot detached. ClearChoiceStyle(view); + try { + if (view.selectionGroupDetached && view.state) + if (auto* control = Target(view.selectionControl)) { + NativeViewState::RenderScope suppress(*view.state); + void* args[]{Target(view.selectionGroup)}; + Call(control, "set_group", 1, args); + } + } catch (...) { + Warn("settings selection group restoration unavailable"); + } + Free(view.selectionGroup); + view.selectionGroupDetached = false; ClearInlineEditor(view); Free(view.widget); Free(view.context); @@ -412,6 +424,14 @@ ValueWidget& Track(Il2CppObject* widget, Il2CppObject* context) if (Target(view.widget) || view.rendering || view.binding || view.requesting || view.clearing || view.editInvoking) continue; Clear(view); + struct BindingScope { + bool& binding; + explicit BindingScope(bool& flag) + : binding(flag) + { binding = true; } + ~BindingScope() + { binding = false; } + } binding(view.binding); auto& metadata = WidgetMeta(widget); Root label(ReadField(widget, metadata.labelField)); Root widgetTransform(Call(widget, "get_transform")); @@ -468,6 +488,18 @@ ValueWidget& Track(Il2CppObject* widget, Il2CppObject* context) BindInlineEditor(view, *SliderFor(context)); if (metadata.selection || metadata.slider) view.selectionControl = weak(selectionControl.get()); + if (metadata.selection && ChoiceFor(context).first->multiple()) { + // A pooled native prefab may belong to an exclusive ToggleGroup. + // Detach only our multi-select and restore its group when released. + Root group(Call(selectionControl.get(), "get_group")); + if (group.get()) { + view.selectionGroup = weak(group.get()); + view.selectionGroupDetached = true; + NativeViewState::RenderScope suppress(*view.state); + void* args[]{nullptr}; + Call(selectionControl.get(), "set_group", 1, args); + } + } if (!metadata.selection) for (std::size_t i = 0; i < indicators.size(); ++i) view.indicators[i] = weak(indicators[i]); diff --git a/mods/src/settings/native_view_state.h b/mods/src/settings/native_view_state.h index dd75fee31..fa5d3849d 100644 --- a/mods/src/settings/native_view_state.h +++ b/mods/src/settings/native_view_state.h @@ -20,6 +20,8 @@ class NativeViewState , group_(&setting) , index_(index) { + if (index < 0 || index >= static_cast(setting.labels().size())) + throw std::invalid_argument("choice row index"); } explicit NativeViewState(SliderSetting& setting) : slider_(std::in_place, setting.state()) @@ -86,15 +88,28 @@ class NativeViewState if (!choice_) return std::nullopt; auto selected = choice_->value(); - return selected ? std::optional(*selected == index_) : std::nullopt; + return selected ? std::optional(group_->Contains(*selected, index_)) : std::nullopt; } int selected() const - { return choice_ ? choice_->value().value_or(-1) : -1; } + { + // Native selection rows compare one index. For a multi-select, each row + // independently presents its own index when checked, or -1 when unchecked. + return choice_ ? (group_->multiple() ? (value().value_or(false) ? index_ : -1) : choice_->value().value_or(-1)) + : -1; + } Outcome Request(bool desired) { if (boolean_) return boolean_->Request(desired).outcome; - return choice_ ? (desired ? choice_->Request(index_).outcome : Outcome::Unchanged) : Outcome::Rejected; + if (!choice_) + return Outcome::Rejected; + if (!group_->multiple()) + return desired ? choice_->Request(index_).outcome : Outcome::Unchanged; + const auto selected = choice_->value(); + if (!selected) + return Outcome::Rejected; + const auto bit = 1 << index_; + return choice_->Request(desired ? *selected | bit : *selected & ~bit).outcome; } Outcome Request(float desired) { diff --git a/mods/src/settings/upstream_settings.cc b/mods/src/settings/upstream_settings.cc index ba485280e..c28540319 100644 --- a/mods/src/settings/upstream_settings.cc +++ b/mods/src/settings/upstream_settings.cc @@ -16,6 +16,7 @@ #include "warp_mode.h" #include +#include #include #include #include @@ -405,22 +406,46 @@ namespace "fleet management after changing them.")); ExactEntry(catalog, page, setting, parse); } - auto factions = Keep>(MakeDefinition( - "ui", "daily_bulk_claim_factions", "Daily bulk-claim factions", DefaultConfig::UI::daily_bulk_claim_factions, - [] { return Join(Config::Get().daily_bulk_claim_factions); }, - [](const std::string& text) { - std::vector values; - if (!ParseFactionFilter(text, values)) - return false; - Config::Get().daily_bulk_claim_factions = std::move(values); - return true; - }, - Hook(&Config::installDailyFactionBulkClaimHooks), Timing::ReopenView, - "Comma-separated faction names; reopen the daily faction claim screen after changing the list.")); - ExactEntry(catalog, "community_mod.claims", factions, [](std::string_view text) -> std::optional { + static constexpr std::array factionNames{"federation", "klingon", "romulan"}; + const auto factionMask = [](const std::vector& values) { + if (values.empty()) + return 7; // Existing blank configs preserve all factions. + int mask = 0; + for (int i = 0; i < 3; ++i) + if (std::ranges::find(values, factionNames[i]) != values.end()) + mask |= 1 << i; + return mask; + }; + const auto factionText = [](int mask) { + if (mask == 0) + return std::string("none"); + if (mask == 7) + return std::string{}; std::vector values; - return ParseFactionFilter(text, values) ? std::optional{Join(values)} : std::nullopt; - }); + for (int i = 0; i < 3; ++i) + if (mask & (1 << i)) + values.emplace_back(factionNames[i]); + return Join(values); + }; + std::vector defaultFactions; + ParseFactionFilter(DefaultConfig::UI::daily_bulk_claim_factions, defaultFactions); + auto factions = Keep( + MakeDefinition( + "ui", "daily_bulk_claim_factions", "Daily bulk-claim factions", factionMask(defaultFactions), + [factionMask] { return factionMask(Config::Get().daily_bulk_claim_factions); }, + [factionText](int mask) { + std::vector values; + if (!ParseFactionFilter(factionText(mask), values)) + return false; + Config::Get().daily_bulk_claim_factions = std::move(values); + return true; + }, + Hook(&Config::installDailyFactionBulkClaimHooks), Timing::ReopenView, + "Select any combination of factions. Uncheck all to skip faction dailies; other dailies remain eligible. " + "Defaults select all factions. Reopen the daily claim screen after changing selections.", + "", [factionText](int mask) -> config_edit::Value { return factionText(mask); }), + std::vector{"Federation", "Klingon", "Romulan"}, ChoiceMode::Multiple); + catalog.AddChoice("community_mod.claims", *factions); for (const bool notify : {false, true}) { #if !_WIN32 if (notify) diff --git a/tests/choice_setting_test.cc b/tests/choice_setting_test.cc index 69fbe336b..c37e6e05a 100644 --- a/tests/choice_setting_test.cc +++ b/tests/choice_setting_test.cc @@ -72,6 +72,36 @@ int main() assert(custom.Request(true) == Outcome::AppliedVerified && audio_value == 10); assert(audio.state().SetFromUser(11, audio.state().Observe()).outcome == Outcome::Rejected); + int factions = 7; + ChoiceSetting multi({"factions", "Factions", [&] { return ValueReadResult::Known(factions, 1); }, + [&](int value, std::uint64_t) { + factions = value; + return ApplyResult::Applied; + }}, + {"Federation", "Klingon", "Romulan"}, ChoiceMode::Multiple); + NativeViewState federation(multi, 0), klingon(multi, 1), romulan(multi, 2); + federation.Bind(); + klingon.Bind(); + romulan.Bind(); + assert(federation.value() == true && klingon.value() == true && romulan.value() == true); + assert(federation.Request(false) == Outcome::AppliedVerified && factions == 6); + assert(klingon.Request(false) == Outcome::Conflict && factions == 6); + klingon.Bind(); + { + NativeViewState::RenderScope guard(klingon); + assert(klingon.Request(false) == Outcome::Suppressed); + } + assert(klingon.Request(false) == Outcome::AppliedVerified && factions == 4); + romulan.Bind(); + assert(romulan.Request(false) == Outcome::AppliedVerified && factions == 0); + assert(multi.SelectionLabel(0) == "None" && multi.SelectionLabel(5) == "Federation, Romulan"); + federation.Bind(); + assert(federation.Request(true) == Outcome::AppliedVerified && factions == 1); + assert(multi.state().SetFromUser(8, multi.state().Observe()).outcome == Outcome::Rejected); + factions = -1; + federation.Bind(); + assert(!federation.value()); + Il2CppType integer{}, nothing{}; integer.type = IL2CPP_TYPE_I4; nothing.type = IL2CPP_TYPE_VOID; diff --git a/tests/upstream_settings_test.cc b/tests/upstream_settings_test.cc index ffb73222e..cccc9fc41 100644 --- a/tests/upstream_settings_test.cc +++ b/tests/upstream_settings_test.cc @@ -5,6 +5,7 @@ #include "file.h" #include "patches/runtime_config.h" #include "settings/mod_pages.h" +#include "settings/native_view_state.h" #include "settings/settings_search.h" #include "settings/text_entry.h" #include "settings/upstream_settings.h" @@ -240,19 +241,19 @@ struct Model { mod_settings::ActionSetting* exact = nullptr; }; -template Model Typed(mod_settings::ValueSetting& state, int choices = 0) +template Model Typed(mod_settings::ValueSetting& state, int choices = 0, bool multiple = false) { Require(state.default_value().has_value(), "typed definition missing default: " + state.id()); return {&state.metadata(), [&state] { state.Reset(state.Observe()); }, [&state] { return state.Observe().state.known(); }, - [&state, choices](const Json&, const std::filesystem::path&) { + [&state, choices, multiple](const Json&, const std::filesystem::path&) { const auto before = state.Observe(); Require(before.state.known(), "unavailable model: " + state.id()); T desired; if constexpr (std::is_same_v) desired = !*before.state.value; else if constexpr (std::is_same_v) - desired = (*before.state.value + 1) % choices; + desired = multiple ? (*before.state.value ^ 1) : (*before.state.value + 1) % choices; else desired = *before.state.value + static_cast(.01f); const auto result = state.SetFromUser(desired, before).outcome; @@ -633,7 +634,7 @@ int main(int argc, char** argv) } else if constexpr (std::is_same_v) { value->state().Observe(); models.try_emplace(value->state().metadata().TomlKey(), - Typed(value->state(), static_cast(value->labels().size()))); + Typed(value->state(), static_cast(value->labels().size()), value->multiple())); } else if constexpr (std::is_same_v) { value->state().Observe(); models.try_emplace(value->state().metadata().TomlKey(), Typed(value->state())); @@ -757,10 +758,62 @@ int main(int argc, char** argv) Require(modal && modal->validate(text).empty() && modal->commit(text), "production parser rejected " + text); modal.reset(); }; - edit("ui.daily_bulk_claim_factions", " federation ,klingon ,romulan "); - Require(Config::Get().daily_bulk_claim_factions == std::vector({"federation", "klingon", "romulan"}), + std::vector parsedFactions; + Require(ParseFactionFilter(" federation ,klingon ,romulan ", parsedFactions) + && parsedFactions == std::vector({"federation", "klingon", "romulan"}), "faction whitespace normalization failed"); + Require(ParseFactionFilter(" NoNe , ", parsedFactions) && parsedFactions == std::vector{"none"}, + "explicit no-factions setting was not retained"); + Require(!ParseFactionFilter("none,federation", parsedFactions) + && parsedFactions == std::vector{"none"}, + "contradictory none filter did not fail closed"); + Require(!ParseFactionFilter("not-a-faction", parsedFactions), "unknown faction accepted"); + Require(ParseFactionFilter("", parsedFactions) && parsedFactions.empty(), "blank faction default changed"); + ChoiceSetting* factionChoice = nullptr; + for (const auto& page : pages) + for (auto* choice : page.Controls()) + if (choice->state().metadata().TomlKey() == "ui.daily_bulk_claim_factions") + factionChoice = choice; + Require(factionChoice && factionChoice->multiple(), "daily factions are not a multi-select"); + NativeViewState federation(*factionChoice, 0), klingon(*factionChoice, 1), romulan(*factionChoice, 2); + federation.Bind(); + klingon.Bind(); + romulan.Bind(); + Require(federation.value() == true && klingon.value() == true && romulan.value() == true, + "blank default did not select all factions"); + for (int mask = 0; mask < 8; ++mask) { + const auto before = factionChoice->state().Observe(); + const auto outcome = factionChoice->state().SetFromUser(mask, before).outcome; + Require(outcome == Outcome::AppliedVerified || outcome == Outcome::Unchanged, + "valid faction combination rejected"); + const auto persisted = std::get(writes.back().value); + Require(persisted + == (mask == 0 ? "none" + : mask == 7 ? "" + : Join(Config::Get().daily_bulk_claim_factions)), + "faction combination saved incorrectly"); + Require(ParseFactionFilter(persisted, parsedFactions) + && parsedFactions == Config::Get().daily_bulk_claim_factions, + "faction combination did not survive startup parsing"); + federation.Bind(); + klingon.Bind(); + romulan.Bind(); + Require(federation.value() == static_cast(mask & 1) && klingon.value() == static_cast(mask & 2) + && romulan.value() == static_cast(mask & 4), + "faction checkbox readback mismatch"); + Require(federation.selected() == (mask & 1 ? 0 : -1) && klingon.selected() == (mask & 2 ? 1 : -1) + && romulan.selected() == (mask & 4 ? 2 : -1), + "native multi-select indices became exclusive"); + } + Require(federation.Request(false) == Outcome::AppliedVerified, "federation did not uncheck"); + klingon.Bind(); + Require(klingon.Request(false) == Outcome::AppliedVerified, "klingon did not uncheck"); + romulan.Bind(); + Require(romulan.Request(false) == Outcome::AppliedVerified + && Config::Get().daily_bulk_claim_factions == std::vector{"none"}, + "unchecking last faction restored all instead of none"); models.at("ui.daily_bulk_claim_factions").reset(); + Require(Config::Get().daily_bulk_claim_factions.empty(), "faction Defaults did not restore blank/all"); edit("ui.disabled_banner_types", "VICTORY ,DEFEAT "); Require(FormatBannerFilter(Config::Get().disabled_banner_types) == "Victory, Defeat", "banner parsing failed"); models.at("ui.disabled_banner_types").reset(); @@ -808,7 +861,6 @@ int main(int argc, char** argv) invalid("control.select_timer", "2147483648"); invalid("ui.cargo_significant_decimals", "7"); invalid("ui.pinned_ships", "*"); - invalid("ui.daily_bulk_claim_factions", "not-a-faction"); invalid("ui.disabled_banner_types", "not-a-banner"); invalid("graphics.loader_image", (directory / "missing.png").string()); From f02124730148c347d6299c1e4f0790800ea2fa09 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 23:34:07 -0500 Subject: [PATCH 14/17] Synchronize multi-select toggles before rendering --- mods/src/settings/native/value_widgets.cc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/mods/src/settings/native/value_widgets.cc b/mods/src/settings/native/value_widgets.cc index 0ff352eb4..d8bdafc1c 100644 --- a/mods/src/settings/native/value_widgets.cc +++ b/mods/src/settings/native/value_widgets.cc @@ -613,6 +613,17 @@ void Render(ValueWidget& view, auto original, Il2CppObject* widget) } } scope(view); Restore(view); + if (WidgetMeta(widget).selection) { + const auto [setting, index] = ChoiceFor(boundContext.get()); + if (setting && setting->multiple()) { + // Native SetWidgetData updates only the animator, leaving Toggle.isOn + // stale. Synchronize it without callbacks so the first click toggles the + // displayed state rather than repeating it. + bool checked = view.state->value().value_or(false); + void* args[]{&checked}; + Call(Target(view.selectionControl), "SetIsOnWithoutNotify", 1, args); + } + } original(widget); if (Target(view.widget) != widget || Target(view.context) != boundContext.get()) return; From 29fcfc6edbd03d6a2d1433d1a8153a37aa82b65d Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 23:37:13 -0500 Subject: [PATCH 15/17] Reconcile multi-select state after native panel binding --- mods/src/settings/native/page_navigation.cc | 15 ++++++++- mods/src/settings/native/value_widgets.cc | 35 ++++++++++++++++++++- mods/src/settings/native/value_widgets.h | 1 + 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/mods/src/settings/native/page_navigation.cc b/mods/src/settings/native/page_navigation.cc index 226fb6ffb..663f4ab47 100644 --- a/mods/src/settings/native/page_navigation.cc +++ b/mods/src/settings/native/page_navigation.cc @@ -94,9 +94,21 @@ const PageCatalog::Heading* CollapsibleHeadingFor(Il2CppObject* context) } bool Collapsed(const PageCatalog::Heading& heading) { return sectionPage.sections.Collapsed(heading); } +// Native OnDidBindContext writes Toggle.isOn after its appearance refresh. +// Reconcile after the complete panel bind, including partial-bind failures. +struct MultiSelectBindScope { + ~MultiSelectBindScope() + { + try { + SynchronizeMultiSelectToggles(); + } catch (...) { + Warn("multi-select post-bind reconciliation unavailable"); + } + } +}; void ShowSections(Il2CppObject* controller, Il2CppObject* context, const PageCatalog::Page& page, bool force = false) { - + MultiSelectBindScope reconcile; SyncActionRows(controller, context, page); Root children(Call(context, "get_Children")); struct OrderedRow { @@ -275,6 +287,7 @@ void CategoryReleaseHook(auto original, Il2CppObject* widget) } void PageSelectedHook(auto original, Il2CppObject* controller, Il2CppObject* context) { + MultiSelectBindScope reconcile; // Keep a redirected context rooted through ClearSectionPage and native bind. Root searchReturn(OnUIThread() ? Target(sectionPage.searchReturn) : nullptr); if (OnUIThread() && pagesActive) { diff --git a/mods/src/settings/native/value_widgets.cc b/mods/src/settings/native/value_widgets.cc index d8bdafc1c..168313ea1 100644 --- a/mods/src/settings/native/value_widgets.cc +++ b/mods/src/settings/native/value_widgets.cc @@ -613,7 +613,7 @@ void Render(ValueWidget& view, auto original, Il2CppObject* widget) } } scope(view); Restore(view); - if (WidgetMeta(widget).selection) { + if (WidgetMeta(widget).selection && !PageRefreshInProgress()) { const auto [setting, index] = ChoiceFor(boundContext.get()); if (setting && setting->multiple()) { // Native SetWidgetData updates only the animator, leaving Toggle.isOn @@ -871,6 +871,39 @@ void SelectionTransitionHook(auto original, Il2CppObject* control, int state, bo return; } } +void SynchronizeMultiSelectToggles() +{ + if (!OnUIThread() || !selectionActive) + return; + for (auto& view : ValueViews()) { + if (!view.state || !view.state->known() || view.rendering || view.binding || view.requesting || view.clearing) + continue; + try { + Root widget(Target(view.widget)), context(Target(view.context)), control(Target(view.selectionControl)); + if (!widget.get() || !context.get() || !control.get() || widget.get()->klass != SelectionMeta().widget.get_cls() + || Invoke(SelectionMeta().getContext, widget.get()) != context.get() || !OwnsValueContext(context.get())) + continue; + const auto [setting, index] = ChoiceFor(context.get()); + if (!setting || !setting->multiple()) + continue; + struct Scope { + bool& rendering; + NativeViewState::RenderScope suppress; + explicit Scope(ValueWidget& view) + : rendering(view.rendering) + , suppress(*view.state) + { rendering = true; } + ~Scope() + { rendering = false; } + } scope(view); + bool checked = view.state->value().value_or(false); + void* args[]{&checked}; + Call(control.get(), "SetIsOnWithoutNotify", 1, args); + } catch (...) { + Warn("multi-select toggle reconciliation unavailable"); + } + } +} void RefreshViews() { if (!OnUIThread()) diff --git a/mods/src/settings/native/value_widgets.h b/mods/src/settings/native/value_widgets.h index 2bd60645e..4e486ab42 100644 --- a/mods/src/settings/native/value_widgets.h +++ b/mods/src/settings/native/value_widgets.h @@ -62,6 +62,7 @@ void PrepareValueViews(std::size_t count); bool ValueWidgetsBusy(); bool InlineSliderEditorActive(Il2CppObject* context); void RefreshViews(); +void SynchronizeMultiSelectToggles(); void InstallChoiceAndSliderWidgets(); bool InstallCoreValueWidgets(); // True only for this first successful installation. } // namespace mod_settings::native From a9654eba5341455452923bf32890bce11a404f25 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 17 Oct 2026 23:38:20 -0500 Subject: [PATCH 16/17] Suppress multi-select edits during native population --- mods/src/settings/native/value_widgets.cc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mods/src/settings/native/value_widgets.cc b/mods/src/settings/native/value_widgets.cc index 168313ea1..26aa299d5 100644 --- a/mods/src/settings/native/value_widgets.cc +++ b/mods/src/settings/native/value_widgets.cc @@ -937,6 +937,11 @@ void ChangeValue(auto original, Il2CppObject* widget, auto desired) Root context(Invoke(WidgetMeta(widget).getContext, widget)); owned = OwnsValueContext(context.get()); if (owned) { + if (PageRefreshInProgress() && WidgetMeta(widget).selection) { + const auto [setting, index] = ChoiceFor(context.get()); + if (setting && setting->multiple()) + return; // Native panel binding cannot authorize a checkbox edit. + } auto* view = FindValueWidget(widget); if (!view || view->rendering || view->binding || view->requesting || view->clearing || Target(view->context) != context.get()) From 31c7d07ad3657d1605e2116604973848b06b2b6b Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 18 Oct 2026 00:17:06 -0500 Subject: [PATCH 17/17] Report installed Kirshara protection readiness in settings --- mods/src/patches/parts/action_queue.cc | 9 +++++++ mods/src/patches/parts/action_queue.h | 7 ++++++ mods/src/patches/parts/queue_address_guard.cc | 7 ++++++ .../patches/parts/thin_queue_protection.cc | 10 ++++++++ mods/src/settings/upstream_settings.cc | 7 +----- tests/upstream_settings_test.cc | 24 +++++++++++++++++++ 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/mods/src/patches/parts/action_queue.cc b/mods/src/patches/parts/action_queue.cc index 6d1b9090b..697df9128 100644 --- a/mods/src/patches/parts/action_queue.cc +++ b/mods/src/patches/parts/action_queue.cc @@ -326,7 +326,16 @@ void InstallActionQueueRecovery() ready.store(a && b && c && d); spdlog::info("[FasterQueueRecovery] ready={}", ready.load()); } +namespace mod_settings +{ +bool QueueRecoveryAvailable() +{ return ready.load() && Config::Get().installActionQueueRecoveryHooks; } +} // namespace mod_settings #else void InstallActionQueueRecovery() {} void ResetQueueRecoveryTracking() {} +namespace mod_settings +{ +bool QueueRecoveryAvailable() { return false; } +} // namespace mod_settings #endif diff --git a/mods/src/patches/parts/action_queue.h b/mods/src/patches/parts/action_queue.h index 35a323aa7..e029a5c97 100644 --- a/mods/src/patches/parts/action_queue.h +++ b/mods/src/patches/parts/action_queue.h @@ -106,3 +106,10 @@ template class Requests std::uint64_t serial_{}; }; } // namespace action_queue + +namespace mod_settings +{ +bool QueueRecoveryAvailable(); +bool ThinQueueProtectionAvailable(); +bool QueueAddressGuardAvailable(); +} // namespace mod_settings diff --git a/mods/src/patches/parts/queue_address_guard.cc b/mods/src/patches/parts/queue_address_guard.cc index 47a806fee..6bd364b2e 100644 --- a/mods/src/patches/parts/queue_address_guard.cc +++ b/mods/src/patches/parts/queue_address_guard.cc @@ -2,6 +2,7 @@ #include #include #include +#include "action_queue.h" #include #include #include @@ -258,3 +259,9 @@ void InstallQueueAddressGuard() ready.store(a && b && c && d); spdlog::info("[QueueAddressGuard] ready={}", ready.load()); } + +namespace mod_settings +{ +bool QueueAddressGuardAvailable() +{ return ready.load() && Config::Get().installQueueAddressGuardHooks; } +} // namespace mod_settings diff --git a/mods/src/patches/parts/thin_queue_protection.cc b/mods/src/patches/parts/thin_queue_protection.cc index 3fdfb0950..09b82635c 100644 --- a/mods/src/patches/parts/thin_queue_protection.cc +++ b/mods/src/patches/parts/thin_queue_protection.cc @@ -1,5 +1,6 @@ #include "patches/action_queue_guard_policy.h" #include +#include "action_queue.h" // Port of the active v2.1.0-guffa.10 guard, not the dormant completion repair. // Windows x64 callback ABI is validated against running metadata; current native fit is recorded separately. @@ -317,6 +318,15 @@ void InstallThinQueueProtection() ready.store(a && b && c); spdlog::info("[ThinQueueProtection] ready={}", ready.load()); } +namespace mod_settings +{ +bool ThinQueueProtectionAvailable() +{ return ready.load() && Config::Get().installThinQueueProtectionHooks; } +} // namespace mod_settings #else void InstallThinQueueProtection() {} +namespace mod_settings +{ +bool ThinQueueProtectionAvailable() { return false; } +} // namespace mod_settings #endif diff --git a/mods/src/settings/upstream_settings.cc b/mods/src/settings/upstream_settings.cc index c28540319..9234c4493 100644 --- a/mods/src/settings/upstream_settings.cc +++ b/mods/src/settings/upstream_settings.cc @@ -507,12 +507,7 @@ void RegisterUpstreamSettings(PageCatalog& catalog) const Available controls = [hotkeys, zoom, pan] { return hotkeys() || zoom() || pan(); }; const Available extended = [hotkeys, zoom] { return hotkeys() || zoom(); }; const Available protections = [] { - const auto& config = Config::Get(); - bool enabled = config.installQueueAddressGuardHooks; -#if defined(_WIN32) && defined(_M_X64) - enabled = enabled || config.installActionQueueRecoveryHooks || config.installThinQueueProtectionHooks; -#endif - return enabled; + return QueueRecoveryAvailable() || ThinQueueProtectionAvailable() || QueueAddressGuardAvailable(); }; const Available queue = [hotkeys, protections] { return hotkeys() || protections() || Config::Get().installTestPatches; diff --git a/tests/upstream_settings_test.cc b/tests/upstream_settings_test.cc index cccc9fc41..3dd5aa6e9 100644 --- a/tests/upstream_settings_test.cc +++ b/tests/upstream_settings_test.cc @@ -41,6 +41,7 @@ std::optional modal; bool labelsAvailable = true; int labelRefreshes = 0, hudRefreshes = 0, shipRefreshes = 0; int queueTrackingResets = 0; +bool queueRecoveryReady = true, thinQueueReady = true, queueAddressReady = true; float previousShipScale = 0, nextShipScale = 0; void Require(bool condition, const std::string& message) @@ -531,6 +532,13 @@ mod_settings::SaveStatus GetSaveStatus(const char* section, const char* key, con } // namespace runtime_config namespace mod_settings { +bool QueueRecoveryAvailable() +{ return fixture::queueRecoveryReady && Config::Get().installActionQueueRecoveryHooks; } +bool ThinQueueProtectionAvailable() +{ return fixture::thinQueueReady && Config::Get().installThinQueueProtectionHooks; } +bool QueueAddressGuardAvailable() +{ return fixture::queueAddressReady && Config::Get().installQueueAddressGuardHooks; } + PageCatalog& ModPages() { static PageCatalog catalog("community_mod.settings", "Mod Settings"); @@ -748,6 +756,22 @@ int main(int argc, char** argv) "queue/protection owners conflated"); }); + // Configured hooks may reject metadata or fail installation: exercise the + // actual catalog through the production readiness aggregator. + queueRecoveryReady = thinQueueReady = queueAddressReady = false; + Require(!models.at("control.kirshara_fixes").available(), "unready protections advertised by catalog"); + const auto beforeUnavailableReset = writes.size(); + const auto beforeUnavailableValue = Config::Get().kirshara_fixes; + models.at("control.kirshara_fixes").reset(); + Require(writes.size() == beforeUnavailableReset && Config::Get().kirshara_fixes == beforeUnavailableValue, + "unavailable protection reset saved or changed a value"); + for (bool* ready : {&queueRecoveryReady, &thinQueueReady, &queueAddressReady}) { + *ready = true; + Require(models.at("control.kirshara_fixes").available(), "one installed protection did not enable catalog"); + *ready = false; + } + queueRecoveryReady = thinQueueReady = queueAddressReady = true; + // The same production parsers serve startup and the exact editor. const std::string bounded = "AbC suffix"; Require(AsciiStrToUpper(std::string_view(bounded).substr(0, 3)) == "ABC"